Repository navigation
Expand file tree
/
Copy pathvalidate-manifests.sh
More file actions
executable file
·1591 lines (1517 loc) · 84.9 KB
/
Copy pathvalidate-manifests.sh
File metadata and controls
executable file
·1591 lines (1517 loc) · 84.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# Validate the plugin marketplace manifests, every plugins/<name>/plugin.json, and the
# plugin catalogue table.
#
# Single source of truth for the checks the 🧪 CI "Validate manifests" job runs:
# 1. Both marketplace manifests (Copilot + Claude) are well-formed (.name + .plugins).
# 2. The two manifests are byte-for-byte equivalent (key-sorted) — no drift.
# 3. Append-only plugin rename history resolves to a current plugin or an explicit removal.
# 4. Every plugins/<name>/plugin.json is complete and well-shaped, and has an
# equivalent .claude-plugin/plugin.json for strict Claude ingestion.
# 5. Manifest entries and on-disk plugins are in lockstep (no missing/orphan plugin,
# no name/description/version/source divergence).
# 6. The plugin catalogue table and on-disk plugin resources are in lockstep (every plugin
# has a row and vice versa; each row's Resources column matches the plugin's bundled
# skills + MCP servers).
# 7. Ancillary *.desired-state.json onboarding resources are structurally complete,
# provider-neutral, placeholder-free, and linked from their plugin README.
#
# Operates on the current working directory (run from the repo root, exactly as CI
# does). Documented in AGENTS.md for local runs and self-tested by
# validate-manifests.test.sh, so the gate stays a single source of truth with no
# inline/doc drift. Stops at the first failing check, mirroring the job's
# stop-on-first-failing-step behaviour.
set -euo pipefail
COPILOT_MANIFEST=".github/plugin/marketplace.json"
CLAUDE_MANIFEST=".claude-plugin/marketplace.json"
RENAME_HISTORY="scripts/marketplace-rename-history.json"
README="docs/plugins.md"
# Digest helpers are shared with the desired-state digest generator, so the value this
# gate demands and the value that generator writes cannot drift apart. See
# scripts/sha256.lib.sh.
# shellcheck source=scripts/sha256.lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/sha256.lib.sh"
validator_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=scripts/json-object.lib.sh
source "$validator_dir/json-object.lib.sh"
# Consumers read only a retained complete inventory. Process substitutions hide
# producer failures from their loops, including failures after valid partial output.
inventory_dir=$(mktemp -d "${TMPDIR:-/tmp}/manifest-inventories.XXXXXX") || exit 1
trap 'rm -rf "$inventory_dir"' EXIT
capture_inventory() {
local destination="$1" description="$2"
shift 2
if ! "$@" > "$inventory_dir/$destination"; then
echo "::error::Could not enumerate $description"
return 1
fi
}
# Refuse truncated or empty NUL records before any inventory consumer can skip them.
capture_nul_inventory() {
local destination=$1 description=$2 record=''
capture_inventory "$@" || return 1
while IFS= read -r -d '' record; do
[ -n "$record" ] || { echo "::error::Empty record in $description"; return 1; }
done < "$inventory_dir/$destination"
[ -z "$record" ] || { echo "::error::Incomplete record in $description"; return 1; }
}
# A package cannot rely on a linked manifest or a linked parent outside its bytes.
regular_packaged_file() {
local path=$1 parent
[ -f "$path" ] && [ ! -L "$path" ] || return 1
parent=$(dirname "$path")
while [ "$parent" != . ]; do
[ -d "$parent" ] && [ ! -L "$parent" ] || return 1
parent=$(dirname "$parent")
done
}
# Retain the complete direct package census, including packages without manifests.
validate_package_boundaries() {
local package name
if [ ! -d plugins ] || [ -L plugins ]; then
echo '::error::plugins/ must be a regular packaged directory'; return 1
fi
capture_nul_inventory packages 'direct plugin packages' find plugins -mindepth 1 -maxdepth 1 \
\( -type d -o -type l \) -print0 || return 1
while IFS= read -r -d '' package; do
name=${package##*/}
if ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
echo "::error::$package: package names must be kebab-case"; return 1
fi
if [ -L "$package" ] ||
! regular_packaged_file "$package/plugin.json" ||
! regular_packaged_file "$package/.claude-plugin/plugin.json"; then
echo "::error::$package: every package requires its own regular canonical manifests"; return 1
fi
done < "$inventory_dir/packages"
}
# Default skill discovery includes every direct directory, not just complete matches.
validate_skill_dir() {
local dir=$1 child count=0
if [ ! -d "$dir" ] || [ -L "$dir" ]; then
echo "::error::$dir: skills require regular packaged directories"; return 1
fi
if [ -e "$dir/SKILL.md" ] || [ -L "$dir/SKILL.md" ]; then
echo "::error::$dir: skills/ must contain canonical skill directories, not a root SKILL.md"; return 1
fi
capture_nul_inventory skills 'default skill directories' find "$dir" -mindepth 1 -maxdepth 1 -print0 || return 1
while IFS= read -r -d '' child; do
if [ -L "$child" ]; then echo "::error::$child: skills require regular packaged directories"; return 1; fi
[ -d "$child" ] || continue
if ! regular_packaged_file "$child/SKILL.md"; then
echo "::error::$child: skill directory requires its own regular SKILL.md"; return 1
fi
count=$((count+1))
done < "$inventory_dir/skills"
[ "$count" -gt 0 ] || { echo "::error::$dir: 'skills/' present but contains no <skill>/SKILL.md"; return 1; }
}
# 1. A marketplace manifest must parse and carry both required top-level keys.
validate_marketplace_json() {
local manifest="$1"
if ! regular_packaged_file "$manifest"; then echo "::error::$manifest: manifest must be a regular packaged file"; return 1; fi
if ! json_object_unique "$manifest" || ! jq -e -L "$validator_dir" '
include "marketplace-release";
def visible: type == "string" and test("\\S") and (test("[[:cntrl:]]") | not);
(.name | type == "string" and test("\\A[a-z0-9]+(-[a-z0-9]+)*\\z"))
and (.plugins | type == "array" and length > 0
and (map(.name) | length == (unique | length))
and all(.[]; type == "object"
and (.name | type == "string" and test("\\A[a-z0-9]+(-[a-z0-9]+)*\\z"))
and (.description | visible) and (.version | stable_version)))
' "$manifest" > /dev/null 2>&1; then
echo "::error::Invalid $manifest: names must be kebab-case, entries unique, descriptions visible text, and versions stable"
return 1
fi
echo "✓ $manifest is valid"
}
# Capture each producer's status explicitly: diff cannot observe a failed jq
# inside process substitution, even when both producers print nothing.
validate_json_parity() {
local left="$1" right="$2" mismatch="$3" left_json right_json
if ! left_json=$(jq -S . "$left"); then
echo "::error::Could not normalize $left"
return 1
fi
if ! right_json=$(jq -S . "$right"); then
echo "::error::Could not normalize $right"
return 1
fi
if [ "$left_json" != "$right_json" ]; then
echo "::error::$mismatch"
# Diagnostic only; the checked, captured values above decide parity.
diff -u <(printf '%s\n' "$left_json") <(printf '%s\n' "$right_json") || true
return 1
fi
}
# 2. The Copilot and Claude manifests must be identical once key-sorted.
validate_marketplace_parity() {
validate_json_parity "$COPILOT_MANIFEST" "$CLAUDE_MANIFEST" \
'Marketplace manifests are out of sync' || return 1
echo "✓ Marketplace manifests are in sync"
}
# 3. Claude Code persists qualified plugin names in enabledPlugins and pluginConfigs.
# Once this marketplace renames or retires a plugin, its top-level `renames` history is
# therefore a permanent compatibility contract: sources must be retired kebab-case names,
# and every chain must terminate at a current plugin or an explicit null removal. The
# append-only baseline pins every published transition so retaining some newer entry cannot
# hide the accidental deletion of an older persisted rename.
validate_marketplace_renames() {
local manifest="$CLAUDE_MANIFEST"
if ! jq -e '.renames | type == "object" and length > 0' "$manifest" > /dev/null; then
echo "::error::$manifest: must declare non-empty top-level 'renames' migration history"
return 1
fi
if ! json_object_unique "$RENAME_HISTORY" || ! jq -e 'type == "object" and length > 0' "$RENAME_HISTORY" > /dev/null 2>&1; then
echo "::error::$RENAME_HISTORY: persisted plugin rename history must be a non-empty object"
return 1
fi
if ! jq -e --slurpfile history "$RENAME_HISTORY" '
. as $manifest
| all($history[0] | to_entries[];
. as $required
| ($manifest.renames | has($required.key))
and ($manifest.renames[$required.key] == $required.value))
' "$manifest" > /dev/null; then
echo "::error::$manifest: must preserve every persisted plugin rename from $RENAME_HISTORY"
return 1
fi
if ! jq -e '
. as $root
| ($root.plugins | map(.name)) as $active
| all($root.renames | to_entries[];
.key as $old
| .value as $new
| ($old | test("^[a-z0-9-]+$"))
and (($active | index($old)) == null)
and ($new == null or
(($new | type) == "string" and ($new | test("^[a-z0-9-]+$")))))
' "$manifest" > /dev/null; then
echo "::error::$manifest: rename sources must be retired kebab-case plugin names and targets must be kebab-case names or null"
return 1
fi
if ! jq -e '
. as $root
| ($root.plugins | map(.name)) as $active
| def resolves($name; $seen):
if (($seen | index($name)) != null) then false
elif ($root.renames | has($name)) then
$root.renames[$name] as $next
| if $next == null then true
elif ($next | type) != "string" then false
else resolves($next; $seen + [$name])
end
else (($active | index($name)) != null)
end;
all($root.renames | keys[]; . as $old | resolves($old; []))
' "$manifest" > /dev/null; then
echo "::error::$manifest: rename chains must terminate at a current plugin or null without cycles"
return 1
fi
echo "✓ Marketplace plugin rename history is valid"
}
# A bundled MCP server (ADR 0001 §D3): an .mcp.json must be valid JSON with a
# non-empty '.mcpServers' object, each server carrying a 'command' (stdio transport)
# or a 'url' (remote transport).
validate_mcp_json() {
local mcp="$1" document remote_urls
if ! document=$(cat -- "$mcp" | json_source_retain) || ! jq -es 'length == 1 and (.[0] | type == "object")' <<< "$document" > /dev/null 2>&1; then
echo "::error::$mcp: not valid JSON"
return 1
fi
# Streaming paths preserve repeated keys that an ordinary JSON decode discards.
if ! jq --stream -es '
reduce .[] as $event ({complete:{}, valid:true};
if ($event|length)==2 then
.complete as $complete | $event[0] as $path |
.valid = (.valid and (any(range(0;($path|length)+1);
$complete[($path[0:.]|tojson)]==true)|not)) |
.complete[($path|tojson)] = true
else .complete[($event[0][0:-1]|tojson)] = true end) | .valid
' <<< "$document" > /dev/null; then
echo "::error::$mcp: repeated JSON declarations are ambiguous"
return 1
fi
if ! jq -e '.mcpServers | type == "object" and length > 0' <<< "$document" > /dev/null; then
echo "::error::$mcp: '.mcpServers' must be a non-empty object"
return 1
fi
# Catalogue identifiers are literal inline-code tokens, not restricted slugs.
# Delimiters and whitespace cannot be represented faithfully in this table.
if ! jq -e 'all(.mcpServers | keys[]; test("[[:space:][:cntrl:]`|]") | not)' <<< "$document" >/dev/null; then
echo "::error::$mcp: MCP server names must be unambiguous catalogue tokens"; return 1
fi
if ! jq -e '
def nonblank: type == "string" and test("[^[:space:]]");
def process_text: type == "string" and (contains("\u0000") | not);
def environment: type == "object" and all(to_entries[];
(.key | nonblank and process_text and (contains("=") | not)) and (.value | process_text));
def header_name: nonblank and (explode | all(.[];
(. >= 48 and . <= 57) or (. >= 65 and . <= 90) or (. >= 97 and . <= 122) or
(. as $code | [33,35,36,37,38,39,42,43,45,46,94,95,96,124,126] | index($code) != null)));
def header_value: type == "string" and (explode | all(.[];
. == 9 or (. >= 32 and . <= 126) or (. >= 128 and . <= 255)));
def headers: type == "object" and all(to_entries[]; (.key|header_name) and (.value|header_value));
all(.mcpServers | to_entries[];
(.key | nonblank) and (.value | type == "object" and
(if has("command") then
(.command | nonblank and process_text) and (has("url") | not) and
(if has("type") then .type == "stdio" else true end)
else (.url | nonblank) and (.type == "http" or .type == "sse") end) and
(if has("args") then (.args | type == "array" and all(.[]; process_text)) else true end) and
(if has("env") then (.env | environment) else true end) and
(if has("headers") then (.headers | headers) else true end)))
' <<< "$document" > /dev/null; then
echo "::error::$mcp: invalid or missing a 'command' (stdio) or 'url' (remote), transport, arguments, environment or headers"
return 1
fi
if ! remote_urls=$(jq -c '[.mcpServers[] | select(has("url")) | .url]' <<< "$document"); then
echo "::error::$mcp: cannot completely observe remote MCP URLs"; return 1
fi
if [ "$remote_urls" != '[]' ]; then
if [ ! -x "$inventory_dir/mcp-url-validator" ]; then
if ! GOENV=off GOWORK=off GO111MODULE=off GOTOOLCHAIN=local GOFLAGS='' CGO_ENABLED=0 \
GOOS='' GOARCH='' GOCACHEPROG='' GOTMPDIR="$inventory_dir" \
go build -o "$inventory_dir/mcp-url-validator" "$validator_dir/mcp-url-go/main.go"; then
echo "::error::$mcp: could not build the offline remote MCP URL validator"; return 1
fi
fi
if ! "$inventory_dir/mcp-url-validator" <<< "$remote_urls"; then
echo "::error::$mcp: malformed remote MCP URL"; return 1
fi
fi
return 0
}
# Does a top-level key in a Markdown file's YAML frontmatter carry a non-empty value?
# Frontmatter is the block between the first two '---' lines. The value counts as present
# when it is a non-empty inline scalar (`key: value`) OR a block scalar (`key: >-` / `key: |`)
# whose following indented lines are non-blank — so a folded multi-line description satisfies
# it. An empty, quoted-empty (`""`/`''`), comment-only (`# …`), or bare-block-indicator value
# with no body is rejected, and a file with no frontmatter yields no match. Staying awk-only
# (no yq dependency), mirroring validate_skill_provenance.
# shellcheck source=scripts/frontmatter.lib.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/frontmatter.lib.sh"
# A bundled custom-agents resource (ADR 0001 §D1/§D3): an agents/ directory must hold at least
# one agents/*.agent.md, and every agent file must carry YAML frontmatter with a non-empty 'name'
# and 'description' (the neutral cross-tool core). The .agent.md suffix is REQUIRED — it is the
# discovery pattern VS Code and Copilot CLI use, while Claude Code is filename-agnostic, so a bare
# .md agent would pass CI yet be invisible on two of the three supported tools. A body-only or
# placeholder file is rejected.
validate_agent_dir() {
local dir="$1" md count=0 failed=0
for md in "$dir"/*.md; do
[ -e "$md" ] || [ -L "$md" ] || continue
if ! regular_packaged_file "$md"; then echo "::error::$md: agent must be a regular packaged file"; failed=1; continue; fi
count=$((count + 1))
case "$md" in
*.agent.md) ;;
*)
echo "::error::$md: agent files must use the <name>.agent.md suffix (VS Code/Copilot discovery; bare .md is invisible there)"
failed=1
continue
;;
esac
if ! frontmatter_has_value "$md" name; then
echo "::error::$md: agent must declare a non-empty 'name' in its YAML frontmatter"
failed=1
fi
if ! frontmatter_has_value "$md" description; then
echo "::error::$md: agent must declare a non-empty 'description' in its YAML frontmatter"
failed=1
fi
done
if [ "$count" -eq 0 ]; then
echo "::error::$dir: must contain at least one agents/*.agent.md"
return 1
fi
return "$failed"
}
# Declared paths stay within the canonical layout that every supported consumer
# and the shared catalogue/provenance inventories examine. Custom layouts require
# extending those inventories together; an unchecked alternate path is refused.
validate_component_paths() {
local manifest=$1 root=$2 field path relative part current found
local components=()
if [ ! -d plugins ] || [ -L plugins ] || [ ! -d "$root" ] || [ -L "$root" ]; then
echo "::error::$manifest: component parents must be regular packaged directories"; return 1
fi
for field in skills agents; do
jq -e --arg field "$field" 'has($field)' "$manifest" >/dev/null || continue
if ! jq -e --arg field "$field" '.[$field] | type == "array" and all(.[];
type == "string" and test("\\A(\\./)?[A-Za-z0-9_./-]+\\z"))' "$manifest" >/dev/null; then
echo "::error::$manifest: '$field' requires literal relative component paths"; return 1
fi
# shellcheck disable=SC2016 # $field is a jq variable supplied through --arg.
capture_inventory "components-$field" 'declared component paths' jq -r --arg field "$field" '.[$field][]' "$manifest" || return 1
while IFS= read -r path; do
relative=${path#./}; relative=${relative%/}
IFS=/ read -r -a components <<< "$relative"
current=$root
for part in "${components[@]}"; do
if [ -z "$part" ] || [ "$part" = . ] || [ "$part" = .. ]; then
echo "::error::$manifest: component path escapes or ambiguously names the plugin"; return 1
fi
current="$current/$part"
if [ -L "$current" ]; then
echo "::error::$manifest: component path must use regular packaged resources"; return 1
fi
done
case "$field:$relative" in
skills:skills|skills:skills/[A-Za-z0-9_-]*)
if [[ "$relative" != skills && "$relative" != skills/* ]] ||
[[ "${relative#skills/}" == */* ]] || [ ! -d "$current" ]; then
echo "::error::$manifest: skill component must name an existing canonical directory"; return 1
fi
found=0
if [ "$relative" = skills ]; then
if [ -e "$current/SKILL.md" ] || [ -L "$current/SKILL.md" ]; then
echo "::error::$manifest: skills/ must contain canonical skill directories, not a root SKILL.md"; return 1
fi
for part in "$current"/*; do
[ -e "$part" ] || [ -L "$part" ] || continue
if [ -L "$part" ] || { [ -d "$part" ] && { [ -L "$part/SKILL.md" ] || [ ! -f "$part/SKILL.md" ]; }; }; then
echo "::error::$manifest: skill components require complete regular skill directories"; return 1
fi
if [ -d "$part" ]; then found=1; fi
done
elif [ -f "$current/SKILL.md" ] && [ ! -L "$current/SKILL.md" ]; then
found=1
else
echo "::error::$manifest: skill component must contain its own regular SKILL.md"; return 1
fi
[ "$found" -eq 1 ] || { echo "::error::$manifest: skill component contains no regular SKILL.md"; return 1; }
;;
agents:agents/*.agent.md)
if [[ "${relative#agents/}" == */* ]] || [ ! -f "$current" ] ||
! frontmatter_has_value "$current" name || ! frontmatter_has_value "$current" description; then
echo "::error::$manifest: agent component must name a valid existing canonical agent file"; return 1
fi
;;
*) echo "::error::$manifest: unsupported component layout; use canonical skills/ or agents/ paths"; return 1 ;;
esac
done < "$inventory_dir/components-$field"
done
}
# 4. Every plugins/<name>/plugin.json is complete and well-shaped, has an equivalent
# .claude-plugin/plugin.json for strict Claude marketplace ingestion, and declares
# at least one recognized resource (skills/, a bundled .mcp.json, or agents/) —
# ADR 0001 §D3.
validate_plugin_json() {
local failed=0
local pj plugin_dir claude_pj ok plugin_name resource_count
for pj in plugins/*/plugin.json; do
plugin_dir=$(dirname "$pj")
claude_pj="$plugin_dir/.claude-plugin/plugin.json"
ok=1
resource_count=0
if ! json_object_unique "$pj"; then
echo "::error::Invalid $pj"
failed=1
continue
fi
plugin_name=$(jq -r '.name // ""' "$pj")
if ! jq -e '.name | type == "string" and test("\\A[a-z0-9]+(-[a-z0-9]+)*\\z")' "$pj" >/dev/null; then
echo "::error::$pj: name '$plugin_name' must be kebab-case (a-z, 0-9, hyphens)"
ok=0
fi
if ! jq -e '.description | type == "string" and test("\\S") and (test("[[:cntrl:]]") | not)' "$pj" >/dev/null; then
echo "::error::$pj: missing or empty 'description' field"
ok=0
fi
if ! jq -e -L "$validator_dir" 'include "marketplace-release"; .version | stable_version' "$pj" >/dev/null; then
echo "::error::$pj: missing or empty 'version', or not a canonical stable cache version"
ok=0
fi
# Claude Desktop's remote marketplace service validates sourced plugins in strict
# mode and requires the canonical .claude-plugin/plugin.json path. Copilot/VS Code
# consume the portable top-level plugin.json, so keep both normalized JSON documents
# equivalent rather than letting either provider receive a divergent contract.
if [ ! -f "$claude_pj" ]; then
echo "::error::$plugin_dir requires .claude-plugin/plugin.json for strict Claude marketplace ingestion"
ok=0
elif ! json_object_unique "$claude_pj"; then
echo "::error::Invalid $claude_pj"
ok=0
elif ! validate_json_parity "$pj" "$claude_pj" "$claude_pj differs from $pj"; then
ok=0
fi
# This marketplace's portable component-path contract requires arrays when
# skills/agents are declared. Omission retains default directory discovery.
# This is a packaging policy; individual consumers may support other forms.
for field in skills agents; do
if [ "$(jq -e --arg f "$field" 'has($f)' "$pj")" = "true" ] \
&& [ "$(jq -r --arg f "$field" '.[$f] | type' "$pj")" != "array" ]; then
echo "::error::$pj: '$field' must be an array of paths, or omitted to auto-discover $field/ (portable marketplace contract)"
ok=0
fi
done
if ! validate_component_paths "$pj" "$plugin_dir"; then ok=0; fi
# Skills always participate in default discovery; explicit paths add to it.
if [ -e "$plugin_dir/skills" ] || [ -L "$plugin_dir/skills" ]; then
if validate_skill_dir "$plugin_dir/skills"; then resource_count=$((resource_count+1)); else ok=0; fi
fi
if [ -e "$plugin_dir/.mcp.json" ] || [ -L "$plugin_dir/.mcp.json" ]; then
if ! regular_packaged_file "$plugin_dir/.mcp.json"; then
echo "::error::$plugin_dir/.mcp.json: MCP configuration must be a regular packaged file"; ok=0
elif validate_mcp_json "$plugin_dir/.mcp.json"; then resource_count=$((resource_count+1)); else ok=0; fi
fi
# Explicit agent declarations are the consumer's selection. Their files have
# already been validated by validate_component_paths; unselected files stay ancillary.
if jq -e 'has("agents")' "$pj" >/dev/null; then
if jq -e '.agents | type == "array" and length > 0' "$pj" >/dev/null; then resource_count=$((resource_count+1)); fi
elif [ -e "$plugin_dir/agents" ] || [ -L "$plugin_dir/agents" ]; then
if [ ! -d "$plugin_dir/agents" ] || [ -L "$plugin_dir/agents" ]; then
echo "::error::$plugin_dir/agents: agents require a regular packaged directory"; ok=0
elif ! capture_nul_inventory agents 'default agent entries' find "$plugin_dir/agents" -mindepth 1 -maxdepth 1 -print0; then ok=0
elif [ -s "$inventory_dir/agents" ]; then
if validate_agent_dir "$plugin_dir/agents"; then resource_count=$((resource_count+1)); else ok=0; fi
fi
fi
if [ "$resource_count" -eq 0 ]; then
echo "::error::$plugin_dir: must declare at least one resource (skills/, .mcp.json, or agents/)"
ok=0
fi
if [ "$ok" -eq 1 ]; then
echo "✓ $pj ($plugin_name)"
else
failed=1
fi
done
return "$failed"
}
# 5. Manifest entries and on-disk plugins are in lockstep.
validate_marketplace_plugins_parity() {
local failed=0 manifest="$CLAUDE_MANIFEST" entry name source ok pj field
capture_inventory marketplace 'marketplace plugins' jq -c '.plugins[]' "$manifest" || return 1
while IFS= read -r entry; do
name=$(jq -r '.name' <<< "$entry"); source=$(jq -r '.source' <<< "$entry")
ok=1
if [ "$source" != "./plugins/$name" ]; then
echo "::error::$manifest: plugin '$name' source '$source' must be './plugins/$name'"
ok=0
fi
pj="plugins/$name/plugin.json"
if ! regular_packaged_file "$pj"; then
echo "::error::$manifest: plugin '$name' has no $pj on disk"; failed=1; continue
fi
for field in name description version; do
# shellcheck disable=SC2016 # jq variables are supplied through structured arguments.
if ! jq -e --arg field "$field" --argjson entry "$entry" '.[$field] == $entry[$field]' "$pj" >/dev/null; then
echo "::error::$pj: $field differs from manifest entry '$name'"; ok=0
fi
done
if [ "$ok" -eq 1 ]; then echo "✓ $name ↔ $pj"; else failed=1; fi
done < "$inventory_dir/marketplace"
while IFS= read -r -d '' pj; do
name=${pj##*/}
if ! jq -e --arg n "$name" '.plugins[] | select(.name == $n)' "$manifest" >/dev/null; then
echo "::error::plugins/$name is not listed in $manifest"; failed=1
fi
done < "$inventory_dir/packages"
return "$failed"
}
# Resource token names (sorted, space-separated) a plugin bundles, across ALL three
# resource kinds validate_plugin_json accepts (ADR 0001 §D3): every skill directory under
# plugins/<name>/skills/, every MCP server key in an optional plugins/<name>/.mcp.json, AND
# every custom-agent entry under an optional plugins/<name>/agents/ (its basename, with a
# trailing .agent.md — VS Code's discovery suffix, ADR 0001's 2026-07-18 correction — or bare
# .md stripped). These are the tokens the README "Resources" column must list.
# Count EVERY skill directory / agent entry, not only those already fleshed out, so a
# stray/half-added folder (the exact drift this parity check guards against) is surfaced
# rather than silently hidden. Kept in lockstep with validate_plugin_json's resource model
# so a plugin can never satisfy that check with a resource kind this enumerator ignores.
plugin_disk_resources() {
local name="$1" d b mcp="plugins/$1/.mcp.json"
{
for d in "plugins/$name/skills"/*/; do
[ -d "$d" ] || continue
basename "$d"
done
if [ -f "$mcp" ]; then
jq -r '.mcpServers // {} | keys[]' "$mcp"
fi
if jq -e 'has("agents")' "plugins/$name/plugin.json" >/dev/null; then
jq -r '.agents[] | split("/")[-1] | sub("\\.agent\\.md$"; "")' "plugins/$name/plugin.json"
else
for d in "plugins/$name/agents"/*.agent.md; do
[ -e "$d" ] || continue
b="$(basename "$d" .md)"
printf '%s\n' "${b%.agent}"
done
fi
} | sort | tr '\n' ' '
}
# 6. The plugin catalogue table and on-disk plugin resources are in lockstep.
# Table rows look like:
# | [`<name>`](plugins/<name>/) | `skill-a`, `mcp-server-b` | <editorial description> |
# The Resources column lists every bundled skill AND MCP server; the Description
# column stays free prose (plugin.json↔manifest already guards it).
# The backticks below are literal table-cell markers in regex/sed patterns, not command
# substitution — SC2016 (won't-expand) is a false positive here.
# shellcheck disable=SC2016
validate_readme_parity() {
if [ ! -f "$README" ]; then
echo "::error::$README: plugin catalogue is missing"
return 1
fi
local failed=0
local line name readme_resources disk_resources
local readme_names=()
# grep's exit 1 is a complete empty selection; an I/O failure is not.
local catalogue_status=0
grep -E '^\| \[`[a-z0-9-]+`\]' "$README" > "$inventory_dir/catalogue" || catalogue_status=$?
if [ "$catalogue_status" -gt 1 ]; then
echo '::error::Could not enumerate catalogue rows'
return 1
fi
# Each catalogue row: parse the plugin name (col 1) and its Resources column (col 3).
while IFS= read -r line; do
name=$(printf '%s' "$line" | sed -nE 's/^\| \[`([a-z0-9-]+)`\].*/\1/p')
[ -z "$name" ] && continue
readme_names+=("$name")
readme_resources=$(printf '%s' "$line" | awk -F'|' '{print $3}' \
| grep -oE '`[^`][^`]*`' | tr -d '`' | sort | tr '\n' ' ')
# Require the manifest, not just the directory: a stray plugins/<name>/ without a
# plugin.json would otherwise pass here yet stay invisible to the orphan scan below
# (which only iterates plugins/*/plugin.json).
if [ ! -f "plugins/$name/plugin.json" ]; then
echo "::error::$README lists plugin '$name' with no plugins/$name/plugin.json on disk"
failed=1
continue
fi
disk_resources=$(plugin_disk_resources "$name")
if [ "$readme_resources" != "$disk_resources" ]; then
echo "::error::$README Resources for '$name' (${readme_resources% }) differ from on-disk resources (${disk_resources% })"
failed=1
else
echo "✓ $README ↔ plugins/$name (resources: ${disk_resources% })"
fi
done < "$inventory_dir/catalogue"
# Every plugins/<name>/ on disk appears as a catalogue row (no plugin missing from the table).
local pj listed rn
for pj in plugins/*/plugin.json; do
name=$(jq -r '.name' "$pj")
listed=0
for rn in "${readme_names[@]}"; do
[ "$rn" = "$name" ] && listed=1 && break
done
if [ "$listed" -eq 0 ]; then
echo "::error::plugins/$name is not listed in the $README plugin table"
failed=1
fi
done
return "$failed"
}
# 7. A copy-paste desired-state resource is ancillary deployment wiring: plugin runtimes do
# not auto-discover it like skills, MCP servers, or agents, but it ships in the plugin
# directory for a human to paste into any assistant. Keep the contract deliberately small
# and provider-neutral. The generic role remains in the plugin; this document only tells a
# new runtime how to load that role and resolve deployment facts from the consumer AGENTS.md.
validate_desired_state_resources() {
local failed=0 resource_failed resource kind plugin_dir plugin_name readme basename entrypoint
local schedule_source schedule_plugin schedule_agent runtime_asset runtime_asset_sha
local runtime_asset_executable actual_asset_sha
local plugin_root runtime_asset_dir resolved_asset asset_parent asset_component asset_component_path
local parent_linked
local -a asset_components
local entrypoint_sha256 actual_entrypoint_sha256
local portfolio_surveyor_sha256 actual_portfolio_surveyor_sha256
# A linked resources parent is invisible to find's non-following traversal.
# Retain the direct parent census independently, including non-directories.
capture_nul_inventory resource-parents 'desired-state resource parents' \
find plugins -mindepth 2 -maxdepth 2 -name resources -print0 || return 1
while IFS= read -r -d '' resource; do
if [ ! -d "$resource" ] || [ -L "$resource" ]; then
echo "::error::$resource: must be a regular packaged resources directory"
return 1
fi
done < "$inventory_dir/resource-parents"
capture_nul_inventory desired-state 'desired-state resources' \
find plugins -path '*/resources/*.desired-state.json' -print0 || return 1
local canonical_resource="plugins/agentic-engineering/resources/provider-neutral.desired-state.json"
local canonical_seen=0
local delivery_guardrail="Write-capable roles own selected engineering work from claim through exact-head review and merge; issue-only handoff is allowed only for a named external blocker or missing authority."
local version_controlled_delivery="Version-controlled definition surfaces are delivered by draft pull request and owned through exact-head review and merge."
local runtime_local_delivery="Runtime-local definition surfaces are delivered in place: back up the current state, apply the change, validate it, and record the reversible before/after evidence."
local improver_self_observation_contract="The Agent Improver is one of its own measured subjects. Keep the Agentic Engineer execution plane and every Agent Improver observation plane in separate scorecards; never average them together or let one hide the other's regression. Measure observer coverage, calibration, hypothesis discipline, verified intervention effectiveness, reliability, efficiency, and verified rollout throughput. Outcome throughput counts only verified terminal outcomes; productive sessions and work advanced are execution-flow indicators, never improvement verdicts. Observation-plane verdicts require independent computation from an immutable or read-only source, or verification by a separate eligible run or instance; the same Improver's unsupported assertion is UNKNOWN, never success. Activity such as PRs, metrics, reports, and memory writes is not improvement. A version-controlled self-referential change requires an independent green current-head review with all findings resolved. A runtime-local self-referential change requires an independently performed post-dispatch read-back against the recorded pre-change baseline through the consumer's declared runtime verification mechanism; the writer's immediate read-back is not independent verification. Both paths require unchanged companion floors for every applicable scorecard parameter and a later eligible evidence window."
local improver_research_fallback_contract="No-change fallback is research, never idle. After scoring and diagnosis, when no telemetry-backed or direct-maintainer-directed improvement is actionable, run one bounded state-of-the-art research pass before reporting. Research is discovery evidence, never authorization or proof that the current system failed. Use current primary sources, compare the current baseline capability, and route a deduplicated product or operations opportunity as an ENGINEER-CANDIDATE and an agent-process or measurement opportunity as an IMPROVER-CANDIDATE. Research alone never authorizes or ships a change. A null result is RESEARCH-NO-CANDIDATE with the topic cursor advanced; research activity is not a terminal improvement outcome."
local money_guardrail="Spend stewardship never moves money: prepare the financial decision, route it to the maintainer's declared private channel, and keep private financial data out of every public artifact."
# Literal Markdown and JSON field syntax, never shell expansions.
# shellcheck disable=SC2016
local spend_enablement_contract='**Spend stewardship is explicitly opt-in.** During preflight, read `spec.roles["agentic-engineer"].spendStewardshipEnabled` from the single effective desired-state document declared in the consumer **Spend contract**. If no effective document is declared, use the shipped `false` default. An unreadable or invalid declared document, a missing field, or a non-boolean value disables spend and reports the gap. Only literal `true` plus a resolving **Spend contract** enables spend analysis and decisions; it bypasses no private-channel, protected-outcomes, or authority requirement. Only the maintainer may opt in. Never infer enablement from contract presence or past activity, and never change the source or value during a run. While disabled, continue ordinary operate and advance engineering.'
local portfolio_survey_json_vocabulary_contract="**Every \`gh --json\` vocabulary is local to its subcommand.** Use the exact literal field lists prescribed by this definition. Before any ad hoc JSON read, run that same subcommand with bare \`--json\` and validate every requested field against the vocabulary it returns; never transfer a field name between subcommands, and never from a different API surface onto a \`gh --json\` subcommand: a name that is real in a REST payload or a GraphQL schema is not thereby a \`gh --json\` field, and \`gh\` rejects the whole read on one unknown name. The default-branch classifier this definition prescribes consumes the REST \`actions/runs\` payload, where \`path\` and \`created_at\` are genuine — neither is a \`gh run list --json\` field, and that is exactly where the confusion starts. The bare diagnostic intentionally exits nonzero after listing its fields; treat a present vocabulary as successful discovery. If the vocabulary is missing or malformed, or the validated read fails, mark the affected evidence \`QUERY-UNKNOWN\` and report the query error — never translate it to an empty result."
local portfolio_survey_recovery_contract="**Mandatory-query recovery is bounded and resumable.** Process mandatory surfaces in deterministic batches of at most eight candidates. Treat every successful batch as an immutable checkpoint. On failure, partition only the failed batch into two deterministic contiguous halves (the first half gets the extra candidate when the count is odd), execute both halves, and recursively partition each failed half until only failed singleton candidates remain. Never re-run a successful half. Continue unaffected batches and mark only failed singleton candidates \`QUERY-UNKNOWN\`; never discard completed evidence or collapse it into portfolio-wide \`QUERY-UNKNOWN\`."
local portfolio_survey_global_failure_contract="Known candidate-independent failures—exhausted query budget, invalid authentication, or a forge-wide transport failure—must fail the affected mandatory surface closed immediately without splitting. Partition only candidate-specific, shape-specific, or partial failures."
local portfolio_survey_head_revalidation_contract="Before emitting any PR disposition, re-read every checkpointed candidate's current head OID. If it changed, discard only that candidate's stale checkpoint and refresh its mandatory evidence; if refresh fails, emit \`NEEDS-FIX\` with \`QUERY-UNKNOWN\`. Never emit \`CLEAR\`, \`REVIEW-READY\`, or \`MERGE-READY\` from evidence bound to a superseded head."
local portfolio_survey_maintainer_control_contract="Authenticated maintainer controls are mandatory evidence, not optional enrichment. Collect exact-login, non-AI-disclosed maintainer comments for every ownership-gated PR or Advance candidate before classifying or ranking it; a failed control-channel query makes only that candidate \`QUERY-UNKNOWN\`."
local portfolio_survey_fail_closed_contract="An incomplete candidate can never be classified clean: no \`CLEAR\`, \`MERGE-READY\`, \`REVIEW-READY\`, or \"no signal\"."
local portfolio_survey_disclosure_contract="**\`disclosure\` is three-valued and matched by WHICH literal appears, never by where it sits.** Emit exactly one of \`routine\`, \`interactive\`, or \`none\`: \`routine\` when the body carries the deployment's AI-disclosure prefix (match the **structural** prefix the consumer contract defines, never a specific actor word — roles get renamed, and a matcher keyed to one spelling silently reclassifies everything written under the others); \`interactive\` when it carries the deployment's declared interactive-session marker (declared beside the AI-disclosure prefix in **Maintainer channels**; a contract that declares no such marker cannot yield \`interactive\`, so report that gap and emit \`none\` — never guess a literal); and \`none\` when it carries neither, which is genuinely unknown — never a synonym for the maintainer's and never a synonym for the orchestrator's own. Match both literals as a **structural line anywhere in the body**: a line whose content, after leading whitespace and any blockquote \`>\` or list \`-\`/\`*\` markers, begins with the marker (an optional 🤖 may precede it). Never a bare substring, and never anchored to the body start — an interactive marker can be the last line and a routine disclosure can sit under a template heading, so a leads-with test reports \`none\` for both and cannot tell them apart. A marker line counts wherever it appears, **including inside a fenced code block — there is deliberately no fence suppression.** A fence detector is unbounded to specify (an unclosed fence, a nested fence, a blockquoted close token, an indented code block, a backtick inside an info string, a raw HTML block), and every container spelling it must skip is another way for it to swallow a real marker; measured across 1029 PR bodies in a consuming deployment (2026-08-11), a delimiter-aware fence state machine changed zero verdicts. The accepted cost is the cheap direction — a body that fences an example of the interactive literal classifies \`interactive\`, which costs a steer the maintainer can repeat — while a real marker swallowed by a mis-parsed fence would read the maintainer's own commentary as an instruction. When both literals appear, **\`interactive\` wins**. The two values carry asymmetric weight: \`interactive\` is decisive on its own, while \`routine\` only corroborates the orchestrator's creation record, because the routine prefix also appears on maintainer-interactive PRs. The field tells the orchestrator whose control channel a maintainer-login comment on that PR is; it never decides on its own whether the PR may be driven — the orchestrator decides that under the **Trust gate**'s maintainer-PR driving fact, where \`interactive\` revokes driving only when that fact is \`hands-off\`."
local portfolio_survey_disclosure_row="disclosure=<routine|interactive|none>"
# Pinned separately from the ownership row above: a bare-token search passes while the
# merged-PR channel silently loses the field, since the two rows carry the same token.
local portfolio_survey_comment_disclosure_row="CANDIDATE-MAINTAINER-COMMENT <repo> #<n> (draft?, merged?) — disclosure=<routine|interactive|none>"
local portfolio_survey_call_shape_contract="**Every forge read is one command in one call.** The read-only guard refuses on shape before it ever inspects intent: output redirection, \`;\`, \`&\`, \`&&\`, a newline, command substitution, and any leading program that is neither a forge command nor a reviewed helper this definition names are all denied, so an ordinary shell idiom silently costs the read. Emit exactly one forge command per call and reduce it in-band with \`--paginate\` and \`--jq\`, or a pipe into the allowlisted read-only filters; never redirect to a scratch file. Sweep repositories with one call per repository or one org-wide search, never a \`for\` loop. Take every timestamp from a payload you already read, never from \`date\`. Select with \`--jq\` rather than \`grep -oE\` or \`xargs\`. A shape denial is a lost read that reads exactly like no evidence: mark the affected evidence \`QUERY-UNKNOWN\` and reissue in the admitted shape — never work around the guard."
local portfolio_survey_classifier_argv_contract="**Invoke the classifier only in its flag form, by its resolved installed path:** \`<installed plugin>/scripts/classify-default-branch-ci-runs.sh --repo OWNER/REPO --branch BRANCH --head-sha FULL_SHA\`. The helper and the read-only guard accept nothing else: the guard admits only that exact installed sibling path — never a bare basename, a \`PATH\` lookup, or a relative \`../scripts/\` form — and a positional \`OWNER/REPO BRANCH SHA\` is denied as \`not the guarded remote-mode shape\` while the helper itself exits 2 on it, so every executable invocation must carry the resolved path and all three flags."
if [ -d plugins/agentic-engineering ]; then
if [ ! -e "$canonical_resource" ] && [ ! -L "$canonical_resource" ]; then
echo "::error::$canonical_resource: missing canonical agentic desired-state resource"
failed=1
elif ! regular_packaged_file "$canonical_resource"; then
echo "::error::$canonical_resource: must be a regular packaged desired-state file"
failed=1
elif jq -e . "$canonical_resource" > /dev/null 2>&1 \
&& ! jq -e '.kind == "AgenticEngineeringDesiredState"' "$canonical_resource" > /dev/null; then
echo "::error::$canonical_resource: canonical agentic desired-state resource must use kind AgenticEngineeringDesiredState"
failed=1
fi
fi
while IFS= read -r -d '' resource; do
resource_failed=0
if [ "$resource" = "$canonical_resource" ]; then canonical_seen=1; fi
if ! regular_packaged_file "$resource"; then
echo "::error::$resource: must be a regular packaged desired-state file"
failed=1
continue
fi
if ! json_object_unique "$resource"; then
echo "::error::$resource: not valid JSON"
failed=1
resource_failed=1
continue
fi
plugin_dir=$(dirname "$(dirname "$resource")")
plugin_name=$(basename "$plugin_dir")
readme="$plugin_dir/README.md"
basename=$(basename "$resource")
if [ ! -f "$plugin_dir/plugin.json" ]; then
echo "::error::$resource: $plugin_dir has no plugin.json; desired-state resources must belong to a manifested plugin"
failed=1
continue
fi
if ! jq -e '.kind | type == "string" and length > 0' "$resource" > /dev/null; then
echo "::error::$resource: desired-state kind must be a non-empty string"
failed=1
resource_failed=1
continue
fi
kind=$(jq -r '.kind' "$resource")
if [ "$kind" != "AgenticEngineeringDesiredState" ]; then
echo "::error::$resource: unsupported desired-state kind $kind"
failed=1
continue
fi
if ! jq -e '
.spec.source.providerPolicy == "neutral"
and ([
.. | objects | keys[] | ascii_downcase
| select((contains("provider") or contains("vendor")) and . != "providerpolicy")
] | length == 0)
' "$resource" > /dev/null; then
echo "::error::$resource: must declare neutral provider policy without provider or vendor fields"
failed=1
resource_failed=1
fi
if ! jq -e '
[
.. | strings | ascii_downcase
| select(test("(^|[^a-z0-9])(anthropic|claude|openai|chatgpt|codex|copilot|gemini)([^a-z0-9]|$)"))
] | length == 0
' "$resource" > /dev/null; then
echo "::error::$resource: desired-state values must not name a specific provider"
failed=1
resource_failed=1
fi
if grep -Eiq '<[^>]+>|TODO|CHANGEME|REPLACE_ME|YOUR_ORG|\{\{[^}]+\}\}|__[A-Z][A-Z0-9_]*__|\[INSERT [^]]+\]|\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$[A-Z_][A-Z0-9_]*' "$resource"; then
echo "::error::$resource: must be copy-paste ready with no unresolved placeholders"
failed=1
resource_failed=1
fi
if [ ! -f "$readme" ] || ! grep -qF "](resources/$basename)" "$readme"; then
echo "::error::$resource: must be linked from $readme"
failed=1
resource_failed=1
fi
entrypoint=$(jq -r '.spec.source.entrypoint // ""' "$resource")
if [ "$entrypoint" != "agentic-engineer" ] \
|| [ ! -f "$plugin_dir/agents/$entrypoint.agent.md" ]; then
echo "::error::$resource: entrypoint must resolve to the bundled agentic-engineer agent"
failed=1
resource_failed=1
fi
capture_inventory assets 'required runtime assets' jq -r '
.spec.source.requiredRuntimeAssets[]?
| [(.path // ""), (.sha256 // ""), (.executable // "")]
| @tsv
' "$resource" || return 1
while IFS=$'\t' read -r runtime_asset runtime_asset_sha runtime_asset_executable; do
[ -n "$runtime_asset" ] || continue
case "$runtime_asset" in
/* | .. | ../* | */../* | */..)
echo "::error::$resource: required runtime asset must be a plugin-relative path: $runtime_asset"
failed=1
resource_failed=1
continue
;;
esac
if [ "$runtime_asset_executable" != "true" ]; then
echo "::error::$resource: required runtime asset executable must be true: $runtime_asset"
failed=1
resource_failed=1
continue
fi
if [ ! -f "$plugin_dir/$runtime_asset" ] \
|| [ -L "$plugin_dir/$runtime_asset" ] \
|| [ ! -x "$plugin_dir/$runtime_asset" ]; then
echo "::error::$resource: required runtime asset is missing, linked, or not executable: $runtime_asset"
failed=1
resource_failed=1
continue
fi
plugin_root=$(cd -P "$plugin_dir" && pwd -P)
if ! runtime_asset_dir=$(cd -P "$(dirname "$plugin_dir/$runtime_asset")" 2>/dev/null && pwd -P); then
echo "::error::$resource: required runtime asset parent cannot be resolved: $runtime_asset"
failed=1
resource_failed=1
continue
fi
resolved_asset="$runtime_asset_dir/$(basename "$runtime_asset")"
case "$resolved_asset" in
"$plugin_root"/*) ;;
*)
echo "::error::$resource: required runtime asset resolves outside its plugin: $runtime_asset"
failed=1
resource_failed=1
continue
;;
esac
asset_parent=${runtime_asset%/*}
if [ "$asset_parent" != "$runtime_asset" ]; then
IFS='/' read -r -a asset_components <<< "$asset_parent"
asset_component_path="$plugin_dir"
parent_linked=0
for asset_component in "${asset_components[@]}"; do
if [ -z "$asset_component" ] || [ "$asset_component" = "." ]; then
continue
fi
asset_component_path="$asset_component_path/$asset_component"
if [ -L "$asset_component_path" ]; then
echo "::error::$resource: required runtime asset parent path is linked: $runtime_asset"
failed=1
resource_failed=1
parent_linked=1
break
fi
done
[ "$parent_linked" -eq 0 ] || continue
fi
if ! printf '%s\n' "$runtime_asset_sha" | grep -Eq '^[a-f0-9]{64}$'; then
echo "::error::$resource: required runtime asset sha256 must be a lowercase SHA-256 digest: $runtime_asset"
failed=1
resource_failed=1
continue
fi
actual_asset_sha=$(sha256_bytes "$plugin_dir/$runtime_asset")
if [ "$runtime_asset_sha" != "$actual_asset_sha" ]; then
echo "::error::$resource: required runtime asset digest does not match: $runtime_asset"
failed=1
resource_failed=1
fi
done < "$inventory_dir/assets"
# This is a content-integrity and review gate, not a natural-language semantic parser:
# the canonical block pins the required rule, while the digest makes every other
# entrypoint edit visible as a coordinated desired-state change. Ignore checkout-only
# CRLF conversion so the committed LF digest remains portable without hiding
# a content-changing lone carriage return.
entrypoint_sha256=$(jq -r '.spec.source.entrypointSha256 // ""' "$resource")
if ! printf '%s\n' "$entrypoint_sha256" | grep -Eq '^[a-f0-9]{64}$'; then
echo "::error::$resource: entrypointSha256 must be a lowercase SHA-256 digest"
failed=1
resource_failed=1
elif [ -f "$plugin_dir/agents/$entrypoint.agent.md" ]; then
actual_entrypoint_sha256=$(sha256_file "$plugin_dir/agents/$entrypoint.agent.md")
if [ "$entrypoint_sha256" != "$actual_entrypoint_sha256" ]; then
echo "::error::$resource: entrypoint digest must match the bundled agent"
failed=1
resource_failed=1
fi
fi
portfolio_surveyor_sha256=$(
jq -r '.spec.roles["portfolio-surveyor"].definitionSha256 // ""' "$resource"
)
if ! printf '%s\n' "$portfolio_surveyor_sha256" | grep -Eq '^[a-f0-9]{64}$'; then
echo "::error::$resource: portfolioSurveyor definitionSha256 must be a lowercase SHA-256 digest"
failed=1
resource_failed=1
elif [ -f "$plugin_dir/agents/portfolio-surveyor.agent.md" ]; then
actual_portfolio_surveyor_sha256=$(
sha256_file "$plugin_dir/agents/portfolio-surveyor.agent.md"
)
if [ "$portfolio_surveyor_sha256" != "$actual_portfolio_surveyor_sha256" ]; then
echo "::error::$resource: portfolio-surveyor digest must match the bundled agent"
failed=1
resource_failed=1
fi
fi
agent_improver_sha256=$(
jq -r '.spec.roles["agent-improver"].definitionSha256 // ""' "$resource"
)
if ! printf '%s\n' "$agent_improver_sha256" | grep -Eq '^[a-f0-9]{64}$'; then
echo "::error::$resource: agentImprover definitionSha256 must be a lowercase SHA-256 digest"
failed=1
resource_failed=1
elif [ -f "$plugin_dir/agents/agent-improver.agent.md" ]; then
actual_agent_improver_sha256=$(
sha256_file "$plugin_dir/agents/agent-improver.agent.md"
)
if [ "$agent_improver_sha256" != "$actual_agent_improver_sha256" ]; then
echo "::error::$resource: agent-improver digest must match the bundled agent"
failed=1
resource_failed=1
fi
fi
agent_improvement_skill="$plugin_dir/skills/agent-improvement/SKILL.md"
agent_improvement_skill_sha256=$(
jq -r '.spec.roles["agent-improver"].skillSha256 // ""' "$resource"
)
if ! printf '%s\n' "$agent_improvement_skill_sha256" | grep -Eq '^[a-f0-9]{64}$'; then
echo "::error::$resource: agentImprover skillSha256 must be a lowercase SHA-256 digest"
failed=1
resource_failed=1
elif [ ! -f "$agent_improvement_skill" ]; then
echo "::error::$resource: agent-improvement skill digest must resolve to the bundled skill"
failed=1
resource_failed=1
else
actual_agent_improvement_skill_sha256=$(sha256_file "$agent_improvement_skill")
if [ "$agent_improvement_skill_sha256" != "$actual_agent_improvement_skill_sha256" ]; then
echo "::error::$resource: agent-improvement skill digest must match the bundled skill"
failed=1
resource_failed=1
fi
fi
if ! jq -e '
.spec.source.marketplace == "devantler-tech/agent-plugins"
and .spec.source.updatePolicy == "latest-reviewed-default-branch"
' "$resource" > /dev/null; then
echo "::error::$resource: marketplace and update policy must use the reviewed canonical source"
failed=1
resource_failed=1
fi
if ! jq -e '
def nonempty_string: type == "string" and length > 0;
(.metadata.description | nonempty_string)
and (.spec.source.marketplace | nonempty_string)
and (.spec.source.entrypoint | nonempty_string)
and (.spec.source.updatePolicy | nonempty_string)
and (.spec.runtime.execution.branchNamespace | nonempty_string)
and (.spec.onboarding.copyPasteInstruction | nonempty_string)
and (.spec.onboarding.steps | type == "array" and length > 0
and all(.[]; nonempty_string))
' "$resource" > /dev/null; then
echo "::error::$resource: text fields must be non-empty strings"
failed=1
resource_failed=1
fi
if ! jq -e '
def nonempty_string: type == "string" and length > 0;
([
.metadata.description,
.spec.source.marketplace,
.spec.source.plugin,
.spec.source.entrypoint,
.spec.source.updatePolicy,
.spec.source.providerPolicy,
.spec.source.refreshTiming,
.spec.consumer.canonicalInstructions,
.spec.consumer.repositoryResolution,
.spec.consumer.organizationScopeFrom,
.spec.roles["agentic-engineer"].mode,
.spec.roles["portfolio-surveyor"].mode,
.spec.roles["agent-improver"].enabledWhen,
.spec.roles["agent-improver"].mode,
.spec.runtime.scheduler.definitionStrategy,
.spec.runtime.scheduler.cadenceFrom,
.spec.runtime.scheduler.timezoneFrom,
.spec.runtime.scheduler.reconcilePolicy,
.spec.runtime.scheduler.notificationPolicy,
.spec.runtime.execution.sourceRevision,
.spec.runtime.execution.isolation,
.spec.runtime.execution.branchNamespace,
.spec.runtime.execution.branchNamespacePolicy,
.spec.runtime.execution.permissions,
.spec.runtime.execution.approvalMode,
.spec.runtime.model.selectionPolicy,
.spec.runtime.model.upgradePolicy,
.spec.runtime.model.reasoningPolicy,
.spec.runtime.memory.backendPolicy,
.spec.runtime.memory.contractFrom,
.spec.onboarding.copyPasteInstruction
] | all(.[]; nonempty_string))
and .spec.source.hotSwapDuringRun == false
and .spec.roles["agentic-engineer"].enabled == true
and .spec.roles["portfolio-surveyor"].enabled == true
and .spec.runtime.memory.loadBeforeContract == true
and .spec.runtime.memory.writeBackAfterRun == true
and all(.spec.consumer.requiredContractSections[]; nonempty_string)
and all(.spec.consumer.requiredWhenAgentImproverEnabled[]; nonempty_string)
and all(.spec.consumer.requiredWhenSpendStewardshipEnabled[]; nonempty_string)
and all(.spec.runtime.scheduler.schedules[];