@danmoisan/drm-copilot-mcp 1.1.7 → 1.1.8

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 (44) hide show
  1. package/out/mcp-server.js +73 -1
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +6 -5
  4. package/resources/claude-customizations/.claude/agents/parallel-planner.md +24 -7
  5. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 +8 -5
  6. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +186 -13
  7. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +186 -13
  8. package/resources/claude-customizations/.claude/hooks/persist-session-id.ps1 +16 -5
  9. package/resources/claude-customizations/.claude/lib/bash/parallel-lane-assertion.sh +495 -0
  10. package/resources/claude-customizations/.claude/lib/bash/report-lane-assertion.sh +169 -0
  11. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +7 -5
  12. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +4 -2
  13. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +4 -2
  14. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +2 -0
  15. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +5 -3
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +2 -0
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +6 -4
  18. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +2 -0
  19. package/resources/claude-customizations/.claude/lib/codex-routing/CodexTopology.psm1 +2 -0
  20. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryValidation.psm1 +16 -16
  21. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +2 -0
  22. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +2 -0
  23. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +3 -1
  24. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +2 -0
  25. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +5 -3
  26. package/resources/claude-customizations/.claude/lib/model-routing/ModelRouting.psm1 +2 -0
  27. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorState.psm1 +11 -0
  28. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 +2 -0
  29. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 +4 -2
  30. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 +4 -2
  31. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 +9 -7
  32. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 +4 -2
  33. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 +4 -2
  34. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 +3 -1
  35. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 +4 -2
  36. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 +3 -1
  37. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 +7 -5
  38. package/resources/claude-customizations/.claude/lib/requirements/GeneratedDocumentCounters.psm1 +12 -0
  39. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +4 -4
  40. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +12 -9
  41. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +3 -3
  42. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +8 -3
  43. package/resources/claude-customizations/pack-manifests/core.json +2 -0
  44. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env bash
2
+ # parallel-lane-assertion.sh: sourceable bash port of
3
+ # scripts/dev_tools/parallel_lane_assertion.py. Answers one question for a
4
+ # parallel-run planner: did the hand-authored lane grouping in the manifest's
5
+ # optional `expected_conflict_components` key (invariant M8) survive
6
+ # blast-radius derivation? The module derives the connected components of the
7
+ # DERIVED conflict graph and reports every disagreement with the asserted
8
+ # grouping, in four classes. The Python module remains the repository authority
9
+ # and this file must reproduce its output byte for byte.
10
+ #
11
+ # Responsibilities and boundaries. This module is a DIAGNOSTIC. It never
12
+ # overrides a derived conflict edge, never feeds the cohort computation, never
13
+ # influences scheduling, and writes no artifact and no checkpoint field. Every
14
+ # finding is advisory: the entry point reports and exits 0 whether or not
15
+ # findings were produced, so a disagreement can never block a planning run. The
16
+ # asserted grouping is an ASSERTION, not a declaration.
17
+ #
18
+ # Scope. Every function here is pure in the sense the Python reference declares
19
+ # for itself at scripts/dev_tools/parallel_lane_assertion.py:34-38: it reads the
20
+ # node table pm_parse_manifest already populated plus its own arguments, writes
21
+ # only this module's own PLA_ globals, reads no file and no clock, and starts no
22
+ # process other than `sort`. The single I/O boundary is the entry point
23
+ # .claude/lib/bash/report-lane-assertion.sh, which calls pc_enforce_c_locale
24
+ # before any work, so every sort and character class below is byte ordered.
25
+ #
26
+ # shellcheck disable=SC2034
27
+ # SC2034 is disabled file-wide because the class-token constants and the result
28
+ # globals declared here are written in this file and read by the entry point
29
+ # .claude/lib/bash/report-lane-assertion.sh, which shellcheck analyses as a
30
+ # separate file and therefore cannot see the use.
31
+
32
+ # Resolve this file's own directory so its dependency sources regardless of the
33
+ # caller's working directory.
34
+ PLA_LIB_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
35
+ # shellcheck source=.claude/lib/bash/parallel-manifest-validate.sh
36
+ # shellcheck disable=SC1091
37
+ source "$PLA_LIB_DIR/parallel-manifest-validate.sh"
38
+
39
+ # The four report classes. Each is a stable token so a consumer can group
40
+ # findings without parsing the human-readable detail text.
41
+ PLA_EXPECTED_TOGETHER_DERIVED_APART="expected_together_derived_apart"
42
+ PLA_EXPECTED_APART_DERIVED_TOGETHER="expected_apart_derived_together"
43
+ PLA_MEMBER_NAMES_NO_ITEM="member_names_no_item"
44
+ PLA_ITEM_COVERED_BY_NO_COMPONENT="item_covered_by_no_component"
45
+
46
+ # The single informational class, held as a space-separated set so
47
+ # pc_contains_word decides membership. The other three classes indicate a real
48
+ # disagreement with the derived graph; this one only reports that the assertion
49
+ # was silent about an item.
50
+ PLA_INFORMATIONAL_KINDS="$PLA_ITEM_COVERED_BY_NO_COMPONENT"
51
+
52
+ # Endpoint separator on the command line, matching the `--edges "<a>:<b> ..."`
53
+ # convention of the cohort-computation entry points.
54
+ PLA_EDGE_SEPARATOR=":"
55
+
56
+ # Parsed conflict edges in input order, one `<first> <second>` pair per element.
57
+ # Populated by pla_parse_edges and read by pla_derive_components.
58
+ PLA_EDGES=()
59
+
60
+ pla_is_lexical_integer() {
61
+ # Return 0 when a token is a decimal integer in the strict accepted lexis.
62
+ #
63
+ # Args: $1 = the raw token. The lexis is the one
64
+ # .claude/lib/bash/compute-cohorts.sh:59 already applies, so the entry points
65
+ # agree on what an endpoint is. It is deliberately stricter than Python's
66
+ # int(), which also accepts a leading `+`, a leading zero, an underscore
67
+ # separator, and a non-ASCII decimal digit; those forms are a declared
68
+ # divergence class, excluded rather than reproduced, because reproducing them
69
+ # would need a second lexis no other entry point shares.
70
+ [[ $1 =~ ^-?(0|[1-9][0-9]*)$ ]]
71
+ }
72
+
73
+ pla_parse_edges() {
74
+ # Populate PLA_EDGES from a whitespace-separated `<a>:<b>` edge list.
75
+ #
76
+ # Args: $1 = the raw --edges value; empty or whitespace-only yields no
77
+ # edges. A token that is not two accepted integers separated by a colon is
78
+ # dropped rather than aborting the diagnostic, which would deny the operator
79
+ # the findings that the remaining edges still support. Input order is
80
+ # preserved because it is observable through the derived-component order.
81
+ local text="${1-}" token first second
82
+ PLA_EDGES=()
83
+ local -a tokens=()
84
+ # read -ra rather than an unquoted expansion, so a token carrying a glob
85
+ # character is never subjected to pathname expansion.
86
+ read -ra tokens <<<"$text"
87
+ for token in "${tokens[@]}"; do
88
+ # Partition on the FIRST colon, matching str.partition: a token with two
89
+ # colons yields a second endpoint that still carries one, which the lexis
90
+ # then rejects.
91
+ [[ $token == *"$PLA_EDGE_SEPARATOR"* ]] || continue
92
+ first="${token%%"$PLA_EDGE_SEPARATOR"*}"
93
+ second="${token#*"$PLA_EDGE_SEPARATOR"}"
94
+ pla_is_lexical_integer "$first" || continue
95
+ pla_is_lexical_integer "$second" || continue
96
+ PLA_EDGES+=("$first $second")
97
+ done
98
+ }
99
+
100
+ pla_sort_numeric() {
101
+ # Echo a space-separated integer list sorted ascending.
102
+ #
103
+ # Args: $1 = the space-separated list, $2 = `unique` to collapse repeats.
104
+ # Duplicates are retained by default because a finding's member list
105
+ # reproduces the asserted membership, repeats included, whereas the declared
106
+ # item keys are a set. `sort -n` is used rather than a shell comparison so
107
+ # ordering is numeric under the C locale pc_enforce_c_locale pins.
108
+ local input="${1-}" mode="${2-}" out="" line
109
+ local -a words=()
110
+ read -ra words <<<"$input"
111
+ ((${#words[@]} > 0)) || return 0
112
+ # Negated test so the guard's own status is 0 on both branches under set -e.
113
+ local -a flags=(-n)
114
+ [[ $mode != unique ]] || flags=(-n -u)
115
+ while IFS= read -r line; do
116
+ [[ -n $line ]] || continue
117
+ out="$out $line"
118
+ done < <(printf '%s\n' "${words[@]}" | sort "${flags[@]}")
119
+ printf '%s' "${out# }"
120
+ }
121
+
122
+ # Asserted lanes read from the manifest, as parallel arrays indexed by the
123
+ # position of the entry within the READABLE entries, which is the index the
124
+ # Python reference's enumerate() produces and therefore the index that reaches
125
+ # the `component[{position}]` label.
126
+ PLA_EXPECTED_COUNT=0
127
+ PLA_EXPECTED_HAS_NAME=()
128
+ PLA_EXPECTED_NAMES=()
129
+ PLA_EXPECTED_MEMBERS=()
130
+
131
+ # Declared item keys, space separated, de-duplicated and ascending.
132
+ PLA_ITEM_KEYS=""
133
+
134
+ pla_read_expected_components() {
135
+ # Populate the PLA_EXPECTED_* arrays from expected_conflict_components.
136
+ #
137
+ # Reads defensively: every shape rule is already enforced by invariant M8,
138
+ # so a malformed entry is skipped rather than reported -- a diagnostic must
139
+ # not fail on input a validator already rejects, and must not emit a second
140
+ # message for a defect M8 reports once.
141
+ local root="expected_conflict_components" total position comp_path
142
+ local members_path member_path member_count index members
143
+ local member_type member_value
144
+ PLA_EXPECTED_COUNT=0
145
+ PLA_EXPECTED_HAS_NAME=()
146
+ PLA_EXPECTED_NAMES=()
147
+ PLA_EXPECTED_MEMBERS=()
148
+ [[ $(yp_type_of "$root") == seq ]] || return 0
149
+ total=$(yp_count_of "$root")
150
+ for ((position = 0; position < total; position++)); do
151
+ comp_path="${root}[${position}]"
152
+ [[ $(yp_type_of "$comp_path") == map ]] || continue
153
+ members_path="${comp_path}.members"
154
+ [[ $(yp_type_of "$members_path") == seq ]] || continue
155
+ # A non-string name reads as absent, so the entry is labelled by
156
+ # position; the name is diagnostic only and never carries identity.
157
+ if [[ $(yp_type_of "${comp_path}.name") == str ]]; then
158
+ PLA_EXPECTED_HAS_NAME+=(1)
159
+ PLA_EXPECTED_NAMES+=("$(yp_value_of "${comp_path}.name")")
160
+ else
161
+ PLA_EXPECTED_HAS_NAME+=(0)
162
+ PLA_EXPECTED_NAMES+=("")
163
+ fi
164
+ members=""
165
+ member_count=$(yp_count_of "$members_path")
166
+ # Manifest order, no de-duplication: the reference keeps the authored
167
+ # tuple as written and sorts only when it renders a finding.
168
+ for ((index = 0; index < member_count; index++)); do
169
+ member_path="${members_path}[${index}]"
170
+ member_type=$(yp_type_of "$member_path")
171
+ member_value=$(yp_value_of "$member_path")
172
+ pc_is_positive_integer "$member_type" "$member_value" || continue
173
+ members="$members $member_value"
174
+ done
175
+ PLA_EXPECTED_MEMBERS+=("${members# }")
176
+ PLA_EXPECTED_COUNT=$((PLA_EXPECTED_COUNT + 1))
177
+ done
178
+ }
179
+
180
+ pla_read_manifest_inputs() {
181
+ # Read the asserted lanes and the declared item keys from the node table.
182
+ #
183
+ # The node table must already be populated by pm_parse_manifest. Populates
184
+ # PLA_EXPECTED_COUNT, the three PLA_EXPECTED_* arrays, and PLA_ITEM_KEYS.
185
+ # Either side may be empty, which is the manifest that asserts nothing and
186
+ # the manifest that declares no item.
187
+ pla_read_expected_components
188
+ # pm_declared_issue_nums already applies the positive-integer test the
189
+ # reference's guard applies, so the two agree on what resolves to an item.
190
+ PLA_ITEM_KEYS=$(pla_sort_numeric "$(pm_declared_issue_nums)" unique)
191
+ }
192
+
193
+ # The derived partition: one element per component, each a space-separated
194
+ # ascending member list, ordered by lowest member.
195
+ PLA_COMPONENTS=()
196
+
197
+ pla_derive_components() {
198
+ # Populate PLA_COMPONENTS with the connected components of the conflict
199
+ # graph.
200
+ #
201
+ # Args: $1 = space-separated declared item keys. Reads the parsed edge list
202
+ # from PLA_EDGES. A lane whose items mutually conflict is one connected
203
+ # component; two lanes sharing no edge are two components.
204
+ local keys="$1" root neighbour edge first second current head
205
+ local -a key_tokens=() roots=() neighbours=() queue=()
206
+ local -A adjacency=() seen=()
207
+ PLA_COMPONENTS=()
208
+
209
+ # Seed adjacency from every declared key so an isolated vertex survives as
210
+ # its own single-member component.
211
+ read -ra key_tokens <<<"$keys"
212
+ for root in "${key_tokens[@]}"; do
213
+ adjacency["$root"]=""
214
+ done
215
+
216
+ # Record each conflict on both endpoints; that symmetry, plus the
217
+ # membership test before the append, makes edge direction and repetition
218
+ # irrelevant. A self-loop and an edge naming an undeclared vertex are
219
+ # skipped rather than reported: malformed-edge reporting belongs to the
220
+ # checkpoint validators, and a diagnostic must degrade gracefully.
221
+ for edge in "${PLA_EDGES[@]}"; do
222
+ first="${edge%% *}"
223
+ second="${edge##* }"
224
+ [[ $first != "$second" ]] || continue
225
+ [[ -n ${adjacency["$first"]+set} ]] || continue
226
+ [[ -n ${adjacency["$second"]+set} ]] || continue
227
+ pc_contains_word "${adjacency["$first"]}" "$second" ||
228
+ adjacency["$first"]="${adjacency["$first"]} $second"
229
+ pc_contains_word "${adjacency["$second"]}" "$first" ||
230
+ adjacency["$second"]="${adjacency["$second"]} $first"
231
+ done
232
+
233
+ # Seed a breadth-first walk from each unvisited vertex in ascending key
234
+ # order, making the component sequence a function of the graph alone. A
235
+ # component's first root is necessarily its smallest member -- a smaller one
236
+ # would have been reached from an earlier root and marked seen -- so the
237
+ # components come out ordered by lowest member and need no second sort.
238
+ read -ra roots <<<"$(pla_sort_numeric "$keys" unique)"
239
+ for root in "${roots[@]}"; do
240
+ [[ -z ${seen["$root"]+set} ]] || continue
241
+ seen["$root"]=1
242
+ queue=("$root")
243
+ head=0
244
+ # The visited set is marked at enqueue time, so a vertex reachable by
245
+ # two paths is enqueued once.
246
+ while ((head < ${#queue[@]})); do
247
+ current="${queue[head]}"
248
+ head=$((head + 1))
249
+ read -ra neighbours <<<"${adjacency["$current"]}"
250
+ for neighbour in "${neighbours[@]}"; do
251
+ [[ -z ${seen["$neighbour"]+set} ]] || continue
252
+ seen["$neighbour"]=1
253
+ queue+=("$neighbour")
254
+ done
255
+ done
256
+ PLA_COMPONENTS+=("$(pla_sort_numeric "${queue[*]}" unique)")
257
+ done
258
+ }
259
+
260
+ # Findings in emission order, as parallel arrays. PLA_FINDING_MEMBERS holds the
261
+ # ascending item keys the finding concerns, so a consumer can act on a finding
262
+ # without re-parsing its detail text.
263
+ PLA_FINDING_KINDS=()
264
+ PLA_FINDING_DETAILS=()
265
+ PLA_FINDING_MEMBERS=()
266
+
267
+ # Flat key -> component-index lookups on both sides, which turn every membership
268
+ # question in the comparison into a constant-time test. Neither is ever
269
+ # iterated, so no hash order reaches output.
270
+ declare -gA PLA_DERIVED_INDEX=()
271
+ declare -gA PLA_EXPECTED_INDEX=()
272
+
273
+ pla_add_finding() {
274
+ # Append one finding: $1 = class token, $2 = detail, $3 = member list.
275
+ PLA_FINDING_KINDS+=("$1")
276
+ PLA_FINDING_DETAILS+=("$2")
277
+ PLA_FINDING_MEMBERS+=("$3")
278
+ }
279
+
280
+ pla_count_distinct() {
281
+ # Echo the number of distinct words in a space-separated list.
282
+ # The accumulator is named distinctly from the associative `seen` set in
283
+ # pla_derive_components: shellcheck resolves a variable name file-wide, so
284
+ # reusing the name there makes it read this string as that array.
285
+ local input="${1-}" counted="" word count=0
286
+ local -a words=()
287
+ read -ra words <<<"$input"
288
+ for word in "${words[@]}"; do
289
+ if ! pc_contains_word "$counted" "$word"; then
290
+ counted="$counted $word"
291
+ count=$((count + 1))
292
+ fi
293
+ done
294
+ printf '%s' "$count"
295
+ }
296
+
297
+ pla_component_label() {
298
+ # Echo the label for one asserted lane.
299
+ #
300
+ # Args: $1 = the lane's position. A lane carrying a string name renders as
301
+ # that name in single quotes, including when the name is the empty string,
302
+ # which is still a string; a lane with no usable name renders by position
303
+ # instead. The name is never used for identity.
304
+ local position="$1"
305
+ if ((PLA_EXPECTED_HAS_NAME[position] == 1)); then
306
+ printf "'%s'" "${PLA_EXPECTED_NAMES[position]}"
307
+ else
308
+ printf 'component[%s]' "$position"
309
+ fi
310
+ }
311
+
312
+ pla_render_member_list() {
313
+ # Echo a space-separated integer list in the Python list form `[101, 102]`:
314
+ # square brackets, comma-and-space separator, no trailing comma. An empty
315
+ # list renders as `[]`. Args: $1 = the list.
316
+ local input="${1-}" out="" word
317
+ local -a words=()
318
+ read -ra words <<<"$input"
319
+ for word in "${words[@]}"; do
320
+ if [[ -n $out ]]; then
321
+ out="$out, $word"
322
+ else
323
+ out="$word"
324
+ fi
325
+ done
326
+ printf '[%s]' "$out"
327
+ }
328
+
329
+ pla_find_split_lanes() {
330
+ # Append one finding per asserted lane whose members landed apart.
331
+ #
332
+ # Reads PLA_EXPECTED_* and PLA_DERIVED_INDEX. One finding per lane, not per
333
+ # pair: an operator whose lane was split wants one message naming the lane,
334
+ # not a quadratic list of member pairs. Lanes are visited in manifest order,
335
+ # and a member absent from the derived index is left to the unknown-member
336
+ # class rather than counted here.
337
+ local position key resolved landed distinct
338
+ local -a members=()
339
+ for ((position = 0; position < PLA_EXPECTED_COUNT; position++)); do
340
+ resolved=""
341
+ landed=""
342
+ read -ra members <<<"${PLA_EXPECTED_MEMBERS[position]}"
343
+ for key in "${members[@]}"; do
344
+ [[ -n ${PLA_DERIVED_INDEX["$key"]+set} ]] || continue
345
+ resolved="$resolved $key"
346
+ landed="$landed ${PLA_DERIVED_INDEX["$key"]}"
347
+ done
348
+ distinct=$(pla_count_distinct "$landed")
349
+ ((distinct > 1)) || continue
350
+ pla_add_finding "$PLA_EXPECTED_TOGETHER_DERIVED_APART" \
351
+ "expected component $(pla_component_label "$position") was derived apart: its members occupy $distinct distinct conflict components" \
352
+ "$(pla_sort_numeric "$resolved")"
353
+ done
354
+ }
355
+
356
+ pla_find_merged_lanes() {
357
+ # Append one finding per derived component spanning two asserted lanes.
358
+ #
359
+ # Reads PLA_COMPONENTS and PLA_EXPECTED_INDEX. A derived component touching
360
+ # two asserted lanes means derivation found contention between lanes asserted
361
+ # to be independent. Components are visited in derived order, and a key
362
+ # covered by no asserted lane is left to the uncovered-item class.
363
+ local index key covered lanes distinct component
364
+ local -a members=()
365
+ for ((index = 0; index < ${#PLA_COMPONENTS[@]}; index++)); do
366
+ component="${PLA_COMPONENTS[index]}"
367
+ covered=""
368
+ lanes=""
369
+ read -ra members <<<"$component"
370
+ for key in "${members[@]}"; do
371
+ [[ -n ${PLA_EXPECTED_INDEX["$key"]+set} ]] || continue
372
+ covered="$covered $key"
373
+ lanes="$lanes ${PLA_EXPECTED_INDEX["$key"]}"
374
+ done
375
+ distinct=$(pla_count_distinct "$lanes")
376
+ ((distinct > 1)) || continue
377
+ pla_add_finding "$PLA_EXPECTED_APART_DERIVED_TOGETHER" \
378
+ "derived conflict component $(pla_render_member_list "$component") spans $distinct expected components that were asserted apart" \
379
+ "$(pla_sort_numeric "$covered")"
380
+ done
381
+ }
382
+
383
+ pla_build_indexes() {
384
+ # Populate the two flat key -> component-index lookups.
385
+ #
386
+ # The expected index is built in manifest order, so when one key appears in
387
+ # two asserted lanes the LAST occurrence wins. That is reproduced from the
388
+ # reference deliberately and no error is reported for it: duplicate membership
389
+ # across components is invariant M8's concern, and reporting it twice for one
390
+ # defect would misdescribe an advisory diagnostic.
391
+ local index position key
392
+ local -a members=()
393
+ PLA_DERIVED_INDEX=()
394
+ PLA_EXPECTED_INDEX=()
395
+ for ((index = 0; index < ${#PLA_COMPONENTS[@]}; index++)); do
396
+ read -ra members <<<"${PLA_COMPONENTS[index]}"
397
+ for key in "${members[@]}"; do
398
+ PLA_DERIVED_INDEX["$key"]="$index"
399
+ done
400
+ done
401
+ for ((position = 0; position < PLA_EXPECTED_COUNT; position++)); do
402
+ read -ra members <<<"${PLA_EXPECTED_MEMBERS[position]}"
403
+ for key in "${members[@]}"; do
404
+ PLA_EXPECTED_INDEX["$key"]="$position"
405
+ done
406
+ done
407
+ }
408
+
409
+ pla_compare() {
410
+ # Compare the asserted lane grouping against the derived components.
411
+ #
412
+ # Args: $1 = space-separated declared item keys. Reads PLA_EXPECTED_* and
413
+ # PLA_EDGES; populates PLA_COMPONENTS and the three PLA_FINDING_* arrays.
414
+ # Findings are grouped by class in the fixed order split, merged, unknown
415
+ # member, uncovered item, so a reader sees the grouping disagreements before
416
+ # the authoring errors and the informational class last.
417
+ local keys="$1" key unknown="" uncovered="" index position
418
+ local -a members=() sorted=()
419
+ pla_derive_components "$keys"
420
+ pla_build_indexes
421
+ PLA_FINDING_KINDS=()
422
+ PLA_FINDING_DETAILS=()
423
+ PLA_FINDING_MEMBERS=()
424
+
425
+ pla_find_split_lanes
426
+ pla_find_merged_lanes
427
+
428
+ # An asserted member naming no manifest item is an authoring error in the
429
+ # assertion itself, so it is reported apart from a grouping disagreement.
430
+ # The candidate list is collected from the authored membership rather than
431
+ # by iterating the index, so no hash order reaches output.
432
+ for ((position = 0; position < PLA_EXPECTED_COUNT; position++)); do
433
+ read -ra members <<<"${PLA_EXPECTED_MEMBERS[position]}"
434
+ for key in "${members[@]}"; do
435
+ [[ -z ${PLA_DERIVED_INDEX["$key"]+set} ]] || continue
436
+ unknown="$unknown $key"
437
+ done
438
+ done
439
+ read -ra sorted <<<"$(pla_sort_numeric "$unknown" unique)"
440
+ for key in "${sorted[@]}"; do
441
+ pla_add_finding "$PLA_MEMBER_NAMES_NO_ITEM" \
442
+ "expected member $key names no manifest item" "$key"
443
+ done
444
+
445
+ # Informational only: an item the assertion did not mention, which is
446
+ # legitimate when the operator asserts a subset of the run.
447
+ for ((index = 0; index < ${#PLA_COMPONENTS[@]}; index++)); do
448
+ read -ra members <<<"${PLA_COMPONENTS[index]}"
449
+ for key in "${members[@]}"; do
450
+ [[ -z ${PLA_EXPECTED_INDEX["$key"]+set} ]] || continue
451
+ uncovered="$uncovered $key"
452
+ done
453
+ done
454
+ read -ra sorted <<<"$(pla_sort_numeric "$uncovered" unique)"
455
+ for key in "${sorted[@]}"; do
456
+ pla_add_finding "$PLA_ITEM_COVERED_BY_NO_COMPONENT" \
457
+ "manifest item $key is covered by no expected component" "$key"
458
+ done
459
+ }
460
+
461
+ # The report's closing line, stating that the diagnostic blocks nothing. Held
462
+ # as a constant so the entry point and the report share one spelling.
463
+ PLA_CLOSING_LINE="Advisory only: this diagnostic never blocks, never modifies a derived edge, never feeds compute_cohorts, and never influences scheduling."
464
+
465
+ # The rendered report, newline separated and with no trailing newline. The
466
+ # single trailing newline is added by the entry point's printf.
467
+ PLA_REPORT=""
468
+
469
+ pla_disagreement_count() {
470
+ # Echo the number of findings outside the informational class.
471
+ local index count=0
472
+ for ((index = 0; index < ${#PLA_FINDING_KINDS[@]}; index++)); do
473
+ if ! pc_contains_word "$PLA_INFORMATIONAL_KINDS" "${PLA_FINDING_KINDS[index]}"; then
474
+ count=$((count + 1))
475
+ fi
476
+ done
477
+ printf '%s' "$count"
478
+ }
479
+
480
+ pla_format_report() {
481
+ # Render the comparison outcome into PLA_REPORT as advisory-only text.
482
+ #
483
+ # A header line naming the derived-component and disagreement counts, one
484
+ # ADVISORY-prefixed line per finding in emission order, and a closing line
485
+ # stating that the diagnostic blocks nothing. The disagreement count covers
486
+ # the first three classes only, because the informational class reports that
487
+ # the assertion was silent rather than that it disagreed.
488
+ local index disagreements
489
+ disagreements=$(pla_disagreement_count)
490
+ PLA_REPORT="Lane assertion: ${#PLA_COMPONENTS[@]} derived conflict component(s); $disagreements disagreement(s)."
491
+ for ((index = 0; index < ${#PLA_FINDING_KINDS[@]}; index++)); do
492
+ PLA_REPORT="$PLA_REPORT"$'\n'"ADVISORY [${PLA_FINDING_KINDS[index]}] ${PLA_FINDING_DETAILS[index]}."
493
+ done
494
+ PLA_REPORT="$PLA_REPORT"$'\n'"$PLA_CLOSING_LINE"
495
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bash
2
+ # report-lane-assertion.sh: destination-portable command-line entry point for
3
+ # the parallel surface's lane-assertion diagnostic. It exists so a workspace
4
+ # that received the Claude customization payload can compare a manifest's
5
+ # hand-authored `expected_conflict_components` assertion against the derived
6
+ # conflict components with nothing but bash -- no Python, no Poetry, no
7
+ # repository checkout.
8
+ #
9
+ # Usage:
10
+ # bash .claude/lib/bash/report-lane-assertion.sh --manifest <path> \
11
+ # [--edges "<a>:<b> <a>:<b> ..."]
12
+ #
13
+ # `--edges` is optional; omitting it, or passing an empty string, means the
14
+ # derived conflict graph has no edges and every declared item is its own
15
+ # component.
16
+ #
17
+ # Output contract:
18
+ # stdout the advisory report: a header line, one ADVISORY line per finding,
19
+ # and the closing line, byte identical to the Python authority
20
+ # scripts/dev_tools/parallel_lane_assertion.py
21
+ # stderr usage text on a usage error
22
+ # exit 0 every non-usage path, including a disagreement, an unreadable
23
+ # manifest, an unparseable manifest, and an out-of-subset manifest
24
+ # exit 2 usage error only
25
+ #
26
+ # The diagnostic is ADVISORY. A disagreement is never expressible as a non-zero
27
+ # exit status, because a planner must not be blocked by a hand-authored
28
+ # assertion that derivation contradicts.
29
+ set -euo pipefail
30
+
31
+ # Resolve this script's own directory so the library sources regardless of cwd.
32
+ RLA_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
33
+ # shellcheck source=.claude/lib/bash/parallel-lane-assertion.sh
34
+ # shellcheck disable=SC1091
35
+ source "$RLA_SCRIPT_DIR/parallel-lane-assertion.sh"
36
+
37
+ pc_enforce_c_locale
38
+
39
+ rla_usage() {
40
+ # Print the entry point's usage text.
41
+ cat <<'EOF'
42
+ Usage: report-lane-assertion.sh --manifest <path> [--edges "<a>:<b> ..."]
43
+
44
+ Compares a parallel manifest's expected_conflict_components assertion against
45
+ the derived conflict components and prints an advisory report on stdout.
46
+
47
+ Options:
48
+ --manifest Path to docs/features/parallel/<slug>/parallel.md (required).
49
+ --edges Derived conflict edges as "<a>:<b> <c>:<d>" (optional).
50
+
51
+ Advisory only: the report never blocks. Every non-usage path exits 0.
52
+ EOF
53
+ }
54
+
55
+ rla_manifest_unreadable_detail() {
56
+ # Echo the reason the manifest could not be read, or nothing when it can.
57
+ #
58
+ # Args: $1 = the manifest path. The Python authority prints str(OSError)
59
+ # here, which names an errno string bash cannot reproduce; that is declared
60
+ # divergence class 4, so only the line's prefix is parity scoped and this
61
+ # detail is deliberately a bash-native phrasing rather than an imitation.
62
+ local path="$1"
63
+ if [[ ! -e $path ]]; then
64
+ printf 'no such file: %s' "$path"
65
+ elif [[ -d $path ]]; then
66
+ printf 'path is a directory: %s' "$path"
67
+ elif [[ ! -r $path ]]; then
68
+ printf 'file is not readable: %s' "$path"
69
+ fi
70
+ }
71
+
72
+ rla_report() {
73
+ # Run the comparison over an already-parsed node table and print the report.
74
+ #
75
+ # The node table must already be populated by pm_parse_manifest. Args:
76
+ # $1 = the raw --edges value.
77
+ pla_read_manifest_inputs
78
+ pla_parse_edges "$1"
79
+ pla_compare "$PLA_ITEM_KEYS"
80
+ pla_format_report
81
+ # One printf with one trailing newline, matching the single print() the
82
+ # Python authority issues, so the two lanes agree byte for byte.
83
+ printf '%s\n' "$PLA_REPORT"
84
+ }
85
+
86
+ rla_main() {
87
+ # Parse arguments, read the manifest, and print the advisory report.
88
+ #
89
+ # Exit 2 is reserved for a usage error -- an unknown flag, a flag missing its
90
+ # value, or an absent --manifest -- and its usage text goes to stderr so it
91
+ # cannot be mistaken for a report. --help is a successful request for the
92
+ # same text, so it goes to stdout and exits 0. Every other path exits 0,
93
+ # because the diagnostic is advisory and a verdict must not be expressible
94
+ # as a non-zero status.
95
+ local manifest="" edges="" manifest_seen=0 text
96
+ while (($# > 0)); do
97
+ case "$1" in
98
+ --manifest)
99
+ (($# >= 2)) || {
100
+ rla_usage >&2
101
+ return 2
102
+ }
103
+ manifest="$2"
104
+ manifest_seen=1
105
+ shift 2
106
+ ;;
107
+ --edges)
108
+ (($# >= 2)) || {
109
+ rla_usage >&2
110
+ return 2
111
+ }
112
+ edges="$2"
113
+ shift 2
114
+ ;;
115
+ --help | -h)
116
+ rla_usage
117
+ return 0
118
+ ;;
119
+ *)
120
+ rla_usage >&2
121
+ return 2
122
+ ;;
123
+ esac
124
+ done
125
+ ((manifest_seen == 1)) || {
126
+ rla_usage >&2
127
+ return 2
128
+ }
129
+
130
+ # An unreadable manifest is reported and exits 0, not 2: the operator asked
131
+ # for a diagnostic and the diagnostic's answer is that it could not look.
132
+ local unreadable
133
+ unreadable=$(rla_manifest_unreadable_detail "$manifest")
134
+ if [[ -n $unreadable ]]; then
135
+ printf 'Lane assertion: manifest unreadable (%s); no comparison made.\n' "$unreadable"
136
+ return 0
137
+ fi
138
+
139
+ text=$(cat -- "$manifest")
140
+ pc_errors_reset
141
+ local parse_status=0
142
+ pm_parse_manifest "$text" || parse_status=$?
143
+ if ((parse_status == 2)); then
144
+ # Status 2 is the scanner's refusal to model a construct. It is reported
145
+ # on its own line, distinct from the unparseable line, because a refusal
146
+ # is not a verdict about the manifest: the Python authority would parse
147
+ # this document, and saying so is more useful than a guessed answer.
148
+ printf 'Lane assertion: manifest outside the supported YAML subset (%s); no comparison made.\n' \
149
+ "$PM_SUBSET_DETAIL"
150
+ return 0
151
+ fi
152
+ if ((parse_status == 1)); then
153
+ # The M1 message is reused byte for byte from pm_parse_manifest rather
154
+ # than restated here, so the two lanes cannot drift on its wording.
155
+ printf 'Lane assertion: manifest unparseable (%s).\n' "${PC_ERRORS[0]}"
156
+ return 0
157
+ fi
158
+
159
+ rla_report "$edges"
160
+ return 0
161
+ }
162
+
163
+ # Guard so the file can be sourced without executing main. main's return code
164
+ # is captured and re-exited explicitly as the final statement.
165
+ if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
166
+ rla_rc=0
167
+ rla_main "$@" || rla_rc=$?
168
+ exit "$rla_rc"
169
+ fi
@@ -47,15 +47,17 @@
47
47
  - Two empty radii, and an empty radius against a non-empty one, do not
48
48
  conflict. Under-reporting via emptiness is V1's problem at plan time, not
49
49
  the relation's.
50
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
50
51
  #>
51
52
 
52
53
  Set-StrictMode -Version Latest
54
+ $ErrorActionPreference = 'Stop'
53
55
 
54
- Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force
55
- Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force
56
- Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force
57
- Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force
58
- Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force
56
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -ErrorAction Stop
57
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop
58
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop
59
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop
60
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force -ErrorAction Stop
59
61
 
60
62
  # Feature-folder handling. Every radius contains its own feature folder, and a
61
63
  # caller may pass either a bare folder name or an already-qualified path.