@jenga-ai/agent 3.6.0 → 4.0.0

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 (79) hide show
  1. package/lib/generate-skill-allow-list.js +42 -5
  2. package/lib/skill-allow-list.json +1 -1
  3. package/package.json +1 -2
  4. package/project/app/api/lib/resolve-project-root.js +1 -1
  5. package/project/app/package.json +4 -0
  6. package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
  7. package/project/app/ui/dist/assets/index-C3oiuli_.js +104 -0
  8. package/project/app/ui/dist/index.html +2 -2
  9. package/scripts/acquire-concurrency-slot.sh +34 -4
  10. package/scripts/apply-j-prefix.sh +1 -1
  11. package/scripts/delete-bare-skill-dirs.sh +1 -2
  12. package/scripts/idea_manager.sh +16 -1
  13. package/scripts/release-concurrency-slot.sh +34 -4
  14. package/scripts/render-ranked-list.sh +270 -0
  15. package/scripts/repoint-dead-bare-path-prose.py +81 -0
  16. package/scripts/rewrite-stale-skill-preambles.py +188 -0
  17. package/scripts/strip-polyfill-frontmatter.py +166 -0
  18. package/scripts/todo_manager.sh +16 -1
  19. package/scripts/validate-typed-object.sh +750 -0
  20. package/skills/j-brainstorm/SKILL.md +3 -4
  21. package/skills/j-btw/SKILL.md +3 -4
  22. package/skills/j-clearify/SKILL.md +3 -4
  23. package/skills/j-close-story/SKILL.md +11 -12
  24. package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
  25. package/skills/j-commit/SKILL.md +3 -4
  26. package/skills/j-continue/SKILL.md +5 -6
  27. package/skills/j-deep-dive/SKILL.md +3 -4
  28. package/skills/j-distribute/SKILL.md +3 -4
  29. package/skills/j-do/SKILL.md +13 -15
  30. package/skills/j-doc/SKILL.md +3 -4
  31. package/skills/j-doc-sync/SKILL.md +3 -4
  32. package/skills/j-dooo/SKILL.md +6 -15
  33. package/skills/j-error/SKILL.md +3 -4
  34. package/skills/j-evaluate/SKILL.md +3 -4
  35. package/skills/j-examplify/SKILL.md +3 -4
  36. package/skills/j-help/SKILL.md +3 -4
  37. package/skills/j-idea/SKILL.md +3 -4
  38. package/skills/j-improve/SKILL.md +3 -4
  39. package/skills/j-init/SKILL.md +20 -11
  40. package/skills/j-init/scripts/apply-scaffold-visibility.sh +8 -6
  41. package/skills/j-jbp/SKILL.md +3 -4
  42. package/skills/j-lgtm/SKILL.md +3 -4
  43. package/skills/j-pi-plan/SKILL.md +3 -4
  44. package/skills/j-proceed/SKILL.md +3 -4
  45. package/skills/j-publish/SKILL.md +3 -4
  46. package/skills/j-publish/scripts/run_gates.sh +1 -1
  47. package/skills/j-reconcile/SKILL.md +38 -5
  48. package/skills/j-reconcile/assets/report_format.md +11 -0
  49. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +2 -2
  50. package/skills/j-reconcile-origin/SKILL.md +3 -4
  51. package/skills/j-redo/SKILL.md +3 -4
  52. package/skills/j-skillify/SKILL.md +3 -4
  53. package/skills/j-spinoff/SKILL.md +3 -4
  54. package/skills/j-status/SKILL.md +4 -5
  55. package/skills/j-todo/SKILL.md +42 -5
  56. package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
  57. package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
  58. package/skills/j-uncharted/SKILL.md +251 -12
  59. package/skills/j-uncharted/scripts/detect-dependencies.sh +79 -21
  60. package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
  61. package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
  62. package/skills/j-uncharted/scripts/run-engine.sh +36 -2
  63. package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
  64. package/skills/j-wtf/SKILL.md +3 -4
  65. package/skills/jenga/SKILL.md +69 -8
  66. package/skills/jenga/playbooks/schema.json +4 -4
  67. package/skills/jenga/scripts/load-nl-catalog.js +5 -2
  68. package/skills/jenga/scripts/load-playbooks.sh +289 -4
  69. package/skills/jenga/scripts/match-playbook.sh +4 -4
  70. package/skills/jenga/scripts/run-playbook-step.sh +267 -1
  71. package/templates/permission-levels/level-1-locked.json +1 -1
  72. package/templates/permission-levels/level-2-guarded.json +1 -1
  73. package/templates/permission-levels/level-3-standard.json +1 -1
  74. package/templates/permission-levels/level-4-elevated.json +1 -1
  75. package/templates/permission-levels/level-5-unrestricted.json +1 -1
  76. package/templates/playbook-types.json +6 -0
  77. package/project/app/ui/dist/assets/index-BVR_7Owg.css +0 -1
  78. package/project/app/ui/dist/assets/index-CtU2xLQm.js +0 -104
  79. package/scripts/audit-twin-divergence.sh +0 -693
@@ -0,0 +1,750 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # scripts/validate-typed-object.sh
4
+ #
5
+ # The single deterministic answer to "does this value conform to this type?"
6
+ # for Epic E62's playbook type descriptors (E62_S01_T02). Consumed by
7
+ # E62_S01_T04 at playbook LOAD time and by E62_S02 at RUNTIME, which is why it
8
+ # lives in shared scripts/ rather than under skills/jenga/scripts/.
9
+ #
10
+ # ---------------------------------------------------------------------------
11
+ # NOTHING ABOUT THE TYPE SYSTEM IS HARDCODED HERE
12
+ # ---------------------------------------------------------------------------
13
+ # Every type name, every `verify` rule kind, and every `normalize` transform
14
+ # name is read out of templates/playbook-types.json on each invocation. Grep
15
+ # this file for `id_list`, `file_list` or `text` and you will find them only in
16
+ # comments. That is the whole point: the registry's own header asserts that
17
+ # "adding a TYPE is a DATA-ONLY edit and NEVER a code change", and a validator
18
+ # carrying its own copy of the type list would silently make that claim false.
19
+ #
20
+ # The registry's header is equally precise about the one exception, and this
21
+ # script implements exactly that line:
22
+ #
23
+ # adding a TYPE -> data only, works here with zero code change
24
+ # adding a RULE -> requires code, because something has to implement it
25
+ # adding a TRANSFORM -> requires code, for the same reason
26
+ #
27
+ # So there are two distinct authorities, and they are deliberately not the same
28
+ # thing:
29
+ #
30
+ # * the REGISTRY (`verify_rules`, `normalize_vocabulary`) is the authority on
31
+ # what is ALLOWED to appear in a descriptor;
32
+ # * the IMPLEMENTED_* constants below are the authority on what this script
33
+ # can EXECUTE.
34
+ #
35
+ # A name present in one but not the other is a hard error in whichever
36
+ # direction it points, never a silent skip:
37
+ #
38
+ # in a descriptor but not in the registry enumeration -> exit 6
39
+ # (a hand-edit smuggled in something the closed vocabulary never admitted)
40
+ # in the registry enumeration but not implemented here -> exit 6
41
+ # (the registry is ahead of the validator; the code half of the extension
42
+ # was not written)
43
+ #
44
+ # A silent skip on either path would turn "the format cannot express semantic
45
+ # extraction" back into a soft guideline, which is precisely what E62 rejected
46
+ # at epic level.
47
+ #
48
+ # ---------------------------------------------------------------------------
49
+ # OUT OF SCOPE — a hard boundary, not a preference
50
+ # ---------------------------------------------------------------------------
51
+ # No repair. No conversion between types. No LLM call. No network call. A value
52
+ # that still fails after `normalize` is simply NON-CONFORMING; producing "what
53
+ # the skill probably meant" is semantic extraction, rejected at epic level (see
54
+ # E62's "What was rejected, and why it matters"). The closed transform
55
+ # vocabulary exists so that this cannot creep back in later.
56
+ #
57
+ # Fully deterministic: identical inputs produce byte-identical output on every
58
+ # run. There is no clock, no randomness, no ordering that depends on the
59
+ # filesystem, and LC_ALL is pinned to C below so that POSIX character classes
60
+ # and ranges in a registry regex cannot vary with the caller's locale.
61
+ #
62
+ # ---------------------------------------------------------------------------
63
+ # USAGE
64
+ # ---------------------------------------------------------------------------
65
+ # scripts/validate-typed-object.sh [options] <type> <value>
66
+ # scripts/validate-typed-object.sh [options] <type> - # value on stdin
67
+ # scripts/validate-typed-object.sh [options] --value-file <path> <type>
68
+ #
69
+ # Options:
70
+ # --registry <path> Type registry to read. Default: <script dir>/../
71
+ # templates/playbook-types.json, resolved relative to
72
+ # this script so the repo root and the .claude/ and
73
+ # .agents/ mirrors each use their own copy.
74
+ # --value-file <path> Read the value from a file instead of an argument.
75
+ # -h, --help Print this usage block and exit 0.
76
+ #
77
+ # Environment:
78
+ # JENGA_PLAYBOOK_TYPES_FILE Same as --registry (the flag wins if both are
79
+ # given). This is the test-injection hook, in the
80
+ # style of JENGA_PLAYBOOKS_TEST_ROOT.
81
+ #
82
+ # ---------------------------------------------------------------------------
83
+ # OUTPUT — one JSON object on stdout, on every path
84
+ # ---------------------------------------------------------------------------
85
+ # {
86
+ # "outcome": "conforming" | "normalized" | "non-conforming" | "error",
87
+ # "type": "<the type name as given>",
88
+ # "raw": "<the value exactly as supplied, before any transform>",
89
+ # "value": "<the value the caller should use>" | null,
90
+ # "normalize_applied": ["<transform>", ...],
91
+ # "reason": "<why>" | null,
92
+ # "error": "<error kind>" // present only when outcome is "error"
93
+ # }
94
+ #
95
+ # `value` is the raw value on the conforming path, the normalized value on the
96
+ # normalized path, and null on the non-conforming and error paths. `reason` is
97
+ # null unless something went wrong.
98
+ #
99
+ # E62_S02 needs to distinguish "already fine" from "I had to fix it" so it can
100
+ # warn: that is `outcome` (conforming vs normalized), and equivalently the exit
101
+ # code (0 vs 2), so a shell consumer can branch without parsing JSON at all.
102
+ #
103
+ # The three OUTCOME paths write to stdout only — stderr stays empty, so a
104
+ # non-conforming value is not noise in a caller's log. The ERROR paths write a
105
+ # human-readable `validate-typed-object.sh: error: ...` line to stderr as well,
106
+ # matching the neighbouring shared helpers.
107
+ #
108
+ # ---------------------------------------------------------------------------
109
+ # EXIT CODES
110
+ # ---------------------------------------------------------------------------
111
+ # 0 conforming — satisfies `verify` as given (or `verify` is null)
112
+ # 1 usage error — bad arguments
113
+ # 2 normalized — did not conform as given, but conforms after
114
+ # `normalize`; the normalized value is in `value`
115
+ # 3 non-conforming — fails `verify` even after `normalize`
116
+ # 4 unknown type — the type name is not a key of the registry's `types`
117
+ # 5 environment — jq missing, registry missing / unreadable /
118
+ # unparseable / not a type registry
119
+ # 6 registry contract violation — a descriptor names a `verify` rule kind or
120
+ # a `normalize` transform that the closed vocabulary
121
+ # does not admit, or that this validator does not
122
+ # implement, or is otherwise malformed
123
+ #
124
+ # ---------------------------------------------------------------------------
125
+ # SEMANTICS
126
+ # ---------------------------------------------------------------------------
127
+ # A value is treated as a list of lines, per the registry header. The list is
128
+ # derived with ordinary POSIX line semantics: the empty value yields zero
129
+ # elements, and a single terminating newline is a terminator rather than an
130
+ # extra empty element ("a\n" is one element, "a\n\n" is two).
131
+ #
132
+ # Evaluation order is fixed:
133
+ # 1. validate the descriptor's contract (rule kinds, transform names) — this
134
+ # happens BEFORE the value is looked at, so a malformed descriptor cannot
135
+ # pass merely because the value happened to be conforming already;
136
+ # 2. `verify: null` short-circuits to conforming — always, unconditionally;
137
+ # 3. verify the value as given -> conforming;
138
+ # 4. otherwise apply `normalize` left to right and verify again ->
139
+ # normalized, or non-conforming.
140
+ #
141
+ # `per_line` is the rule kind defined today: a POSIX ERE that every element of
142
+ # the list must match. Matching uses `grep -E`, NOT jq's `test()`, because the
143
+ # registry's patterns are POSIX ERE by deliberate choice ([0-9] and
144
+ # [^[:space:]], never \d or \S) precisely because the consumer is a shell
145
+ # script. A `verify` object carrying several rule kinds requires all of them to
146
+ # hold.
147
+ #
148
+ # KNOWN SEMANTIC EDGE, deliberately left literal: "every element matches" is
149
+ # vacuously TRUE of an empty list, so a value that normalizes down to nothing
150
+ # (e.g. "" or " , " for id_list) is reported conforming rather than
151
+ # non-conforming. That is the honest reading of the rule as the registry states
152
+ # it. The alternative — a non-empty requirement — is NOT invented here, because
153
+ # inventing it would mean hardcoding a rule the registry never expressed; the
154
+ # right way to express it is a new rule kind in the registry (a data edit plus
155
+ # its implementation), which is exactly the extension path the format provides.
156
+ # Flagged for E62_S02 rather than silently decided here.
157
+ # ---------------------------------------------------------------------------
158
+
159
+ set -euo pipefail
160
+
161
+ # Pathname expansion off. Registry-sourced names are carried in arrays and
162
+ # expanded quoted, so nothing should reach a glob context — this is the belt to
163
+ # that braces. A registry entry of "*" reaching an unquoted expansion would glob
164
+ # against the caller's cwd, making the verdict depend on where the script was
165
+ # run from, which is the one thing it must never do.
166
+ set -f
167
+
168
+ # Pinned for determinism: character classes ([[:space:]]) and ranges in a
169
+ # registry regex must not depend on the caller's locale.
170
+ LC_ALL=C
171
+ export LC_ALL
172
+
173
+ SELF="$(basename "$0")"
174
+
175
+ # What this script can EXECUTE. See the header: this is NOT the vocabulary —
176
+ # the registry owns that — it is the implementation inventory, and the two are
177
+ # cross-checked against each other in both directions before any value is
178
+ # evaluated. Keep each list in step with its dispatcher below.
179
+ IMPLEMENTED_RULE_KINDS=("per_line")
180
+ IMPLEMENTED_TRANSFORMS=("split_on_comma" "trim" "drop_empty")
181
+
182
+ # The bare-identifier shape the registry header mandates for a transform name.
183
+ IDENTIFIER_ERE='^[a-z][a-z0-9_]*$'
184
+
185
+ TYPE_NAME=""
186
+ RAW_VALUE=""
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Output
190
+ # ---------------------------------------------------------------------------
191
+
192
+ usage() {
193
+ cat <<'USAGE'
194
+ Usage:
195
+ validate-typed-object.sh [options] <type> <value>
196
+ validate-typed-object.sh [options] <type> - # value on stdin
197
+ validate-typed-object.sh [options] --value-file <path> <type>
198
+
199
+ Options:
200
+ --registry <path> Type registry (default: ../templates/playbook-types.json
201
+ relative to this script; JENGA_PLAYBOOK_TYPES_FILE also
202
+ sets it).
203
+ --value-file <path> Read the value from a file instead of an argument.
204
+ -h, --help Show this help.
205
+
206
+ Exit codes:
207
+ 0 conforming 1 usage 2 normalized 3 non-conforming
208
+ 4 unknown type 5 environment 6 registry contract violation
209
+ USAGE
210
+ }
211
+
212
+ # emit_outcome <outcome> <exit-code> <value-json> <applied-json> <reason-json>
213
+ emit_outcome() {
214
+ jq -n \
215
+ --arg outcome "$1" \
216
+ --arg type "$TYPE_NAME" \
217
+ --arg raw "$RAW_VALUE" \
218
+ --argjson value "$3" \
219
+ --argjson applied "$4" \
220
+ --argjson reason "$5" \
221
+ '{outcome: $outcome, type: $type, raw: $raw, value: $value, normalize_applied: $applied, reason: $reason}'
222
+ exit "$2"
223
+ }
224
+
225
+ # fail <exit-code> <error-kind> <message>
226
+ fail() {
227
+ local code="$1" kind="$2" msg="$3"
228
+ printf '%s: error: %s\n' "$SELF" "$msg" >&2
229
+ # jq is how the JSON half of the contract gets written, so the one error that
230
+ # cannot honour it is a missing jq. Every other error path does.
231
+ if command -v jq > /dev/null 2>&1; then
232
+ jq -n \
233
+ --arg outcome "error" \
234
+ --arg error "$kind" \
235
+ --arg type "$TYPE_NAME" \
236
+ --arg raw "$RAW_VALUE" \
237
+ --arg reason "$msg" \
238
+ '{outcome: $outcome, type: $type, raw: $raw, value: null, normalize_applied: [], reason: $reason, error: $error}'
239
+ fi
240
+ exit "$code"
241
+ }
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Small helpers
245
+ # ---------------------------------------------------------------------------
246
+
247
+ # list_contains <needle> [<candidate> ...]
248
+ #
249
+ # Membership is compared on WHOLE strings, never on whitespace-split tokens. A
250
+ # hand-edited registry entry such as "extract the ids the skill meant" has to be
251
+ # reported as that entry, in full — splitting it would report a verdict about
252
+ # the token "extract" and describe a registry the user does not have.
253
+ list_contains() {
254
+ local needle="$1" candidate
255
+ shift
256
+ for candidate in "$@"; do
257
+ if [ "$candidate" = "$needle" ]; then
258
+ return 0
259
+ fi
260
+ done
261
+ return 1
262
+ }
263
+
264
+ # read_lines_into <array-name> < <newline-delimited stream>
265
+ #
266
+ # One entry per line, read WHOLE — never split on whitespace. A registry is a
267
+ # JSON file a human can hand-edit, so an entry may legally contain spaces, and
268
+ # "extract the ids the skill meant" has to reach the identifier check intact to
269
+ # be rejected by name rather than by one of its tokens.
270
+ #
271
+ # A newline INSIDE an entry would defeat the line framing, so that case is ruled
272
+ # out up front instead: assert_no_newlines below rejects any such entry before
273
+ # it is ever read here.
274
+ read_lines_into() {
275
+ local name="$1" item
276
+ eval "$name=()"
277
+ while IFS= read -r item; do
278
+ eval "$name+=(\"\$item\")"
279
+ done
280
+ }
281
+
282
+ # assert_no_newlines <jq-filter-yielding-strings> <what>
283
+ #
284
+ # Line framing above is only safe if no entry contains a newline. Rather than
285
+ # silently mis-framing such an entry into two, reject it as the malformed
286
+ # registry content it is.
287
+ assert_no_newlines() {
288
+ jq -e --arg t "$TYPE_NAME" "$1 | all(type == \"string\" and (contains(\"\n\") | not))" \
289
+ "$REGISTRY_FILE" > /dev/null 2>&1 \
290
+ || fail 6 "malformed_registry_entry" "$2 in $REGISTRY_FILE contains an entry that is not a newline-free string"
291
+ }
292
+
293
+ # join_with_comma <item> [<item> ...]
294
+ join_with_comma() {
295
+ local out="" item
296
+ for item in "$@"; do
297
+ if [ -z "$out" ]; then
298
+ out="$item"
299
+ else
300
+ out="$out, $item"
301
+ fi
302
+ done
303
+ printf '%s' "$out"
304
+ }
305
+
306
+ # Reads a whole file (or stdin, when $1 is "-") into RAW_VALUE without losing
307
+ # trailing newlines — command substitution strips them, hence the sentinel.
308
+ read_value_from() {
309
+ local src="$1" buf
310
+ if [ "$src" = "-" ]; then
311
+ buf="$(cat; printf 'x')"
312
+ else
313
+ [ -r "$src" ] || fail 5 "value_file_unreadable" "value file not readable: $src"
314
+ buf="$(cat -- "$src"; printf 'x')"
315
+ fi
316
+ RAW_VALUE="${buf%x}"
317
+ }
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Argument parsing
321
+ # ---------------------------------------------------------------------------
322
+
323
+ REGISTRY_FILE="${JENGA_PLAYBOOK_TYPES_FILE:-}"
324
+ VALUE_FILE=""
325
+ VALUE_GIVEN=0
326
+ POSITIONAL_COUNT=0
327
+ POS_TYPE=""
328
+ POS_VALUE=""
329
+
330
+ add_positional() {
331
+ case "$POSITIONAL_COUNT" in
332
+ 0) POS_TYPE="$1" ;;
333
+ 1) POS_VALUE="$1" ;;
334
+ *) fail 1 "usage" "unexpected extra argument: $1" ;;
335
+ esac
336
+ POSITIONAL_COUNT=$((POSITIONAL_COUNT + 1))
337
+ }
338
+
339
+ END_OF_OPTIONS=0
340
+ while [ "$#" -gt 0 ]; do
341
+ if [ "$END_OF_OPTIONS" -eq 1 ]; then
342
+ add_positional "$1"
343
+ shift
344
+ continue
345
+ fi
346
+ case "$1" in
347
+ --registry)
348
+ [ "$#" -ge 2 ] || fail 1 "usage" "--registry requires a path"
349
+ REGISTRY_FILE="$2"
350
+ shift 2
351
+ ;;
352
+ --registry=*)
353
+ REGISTRY_FILE="${1#*=}"
354
+ shift
355
+ ;;
356
+ --value-file)
357
+ [ "$#" -ge 2 ] || fail 1 "usage" "--value-file requires a path"
358
+ VALUE_FILE="$2"
359
+ shift 2
360
+ ;;
361
+ --value-file=*)
362
+ VALUE_FILE="${1#*=}"
363
+ shift
364
+ ;;
365
+ -h|--help)
366
+ usage
367
+ exit 0
368
+ ;;
369
+ --)
370
+ END_OF_OPTIONS=1
371
+ shift
372
+ ;;
373
+ -)
374
+ add_positional "$1"
375
+ shift
376
+ ;;
377
+ -*)
378
+ fail 1 "usage" "unknown option: $1"
379
+ ;;
380
+ *)
381
+ add_positional "$1"
382
+ shift
383
+ ;;
384
+ esac
385
+ done
386
+
387
+ [ "$POSITIONAL_COUNT" -ge 1 ] || { usage >&2; fail 1 "usage" "a type name is required"; }
388
+ TYPE_NAME="$POS_TYPE"
389
+
390
+ if [ -n "$VALUE_FILE" ]; then
391
+ [ "$POSITIONAL_COUNT" -le 1 ] || fail 1 "usage" "--value-file and a positional value are mutually exclusive"
392
+ read_value_from "$VALUE_FILE"
393
+ VALUE_GIVEN=1
394
+ elif [ "$POSITIONAL_COUNT" -eq 2 ]; then
395
+ if [ "$POS_VALUE" = "-" ]; then
396
+ read_value_from "-"
397
+ else
398
+ RAW_VALUE="$POS_VALUE"
399
+ fi
400
+ VALUE_GIVEN=1
401
+ fi
402
+
403
+ # An absent value and an empty value are different questions, and only the
404
+ # first is a usage error. `validate-typed-object.sh id_list ""` is a legitimate
405
+ # thing to ask.
406
+ [ "$VALUE_GIVEN" -eq 1 ] || { usage >&2; fail 1 "usage" "a value is required (pass it as an argument, as '-' for stdin, or via --value-file)"; }
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Registry resolution and structural sanity
410
+ # ---------------------------------------------------------------------------
411
+
412
+ command -v jq > /dev/null 2>&1 || fail 5 "jq_missing" "jq is required but not found on PATH"
413
+
414
+ if [ -z "$REGISTRY_FILE" ]; then
415
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
416
+ REGISTRY_FILE="$SCRIPT_DIR/../templates/playbook-types.json"
417
+ fi
418
+
419
+ [ -r "$REGISTRY_FILE" ] || fail 5 "registry_unreadable" "type registry not readable: $REGISTRY_FILE"
420
+ jq -e 'type == "object"' "$REGISTRY_FILE" > /dev/null 2>&1 \
421
+ || fail 5 "registry_unparseable" "type registry is not a JSON object: $REGISTRY_FILE"
422
+
423
+ jq -e '(.types | type) == "object"' "$REGISTRY_FILE" > /dev/null 2>&1 \
424
+ || fail 5 "registry_malformed" "type registry has no 'types' object: $REGISTRY_FILE"
425
+ jq -e '(.verify_rules | type) == "array"' "$REGISTRY_FILE" > /dev/null 2>&1 \
426
+ || fail 5 "registry_malformed" "type registry has no 'verify_rules' array: $REGISTRY_FILE"
427
+ jq -e '(.normalize_vocabulary | type) == "array"' "$REGISTRY_FILE" > /dev/null 2>&1 \
428
+ || fail 5 "registry_malformed" "type registry has no 'normalize_vocabulary' array: $REGISTRY_FILE"
429
+
430
+ # The two enumerations, read from the registry. Never assumed, never defaulted.
431
+ assert_no_newlines '.verify_rules' "'verify_rules'"
432
+ assert_no_newlines '.normalize_vocabulary' "'normalize_vocabulary'"
433
+ REGISTRY_RULE_KINDS=()
434
+ REGISTRY_TRANSFORMS=()
435
+ read_lines_into REGISTRY_RULE_KINDS < <(jq -r '.verify_rules[]' "$REGISTRY_FILE")
436
+ read_lines_into REGISTRY_TRANSFORMS < <(jq -r '.normalize_vocabulary[]' "$REGISTRY_FILE")
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Type lookup
440
+ # ---------------------------------------------------------------------------
441
+
442
+ if ! jq -e --arg t "$TYPE_NAME" '.types | has($t)' "$REGISTRY_FILE" > /dev/null 2>&1; then
443
+ KNOWN_TYPES="$(jq -r '.types | keys_unsorted | join(", ")' "$REGISTRY_FILE")"
444
+ fail 4 "unknown_type" "unknown type '$TYPE_NAME' — not a key of 'types' in $REGISTRY_FILE (known types: $KNOWN_TYPES)"
445
+ fi
446
+
447
+ jq -e --arg t "$TYPE_NAME" '(.types[$t] | type) == "object"' "$REGISTRY_FILE" > /dev/null 2>&1 \
448
+ || fail 6 "malformed_descriptor" "descriptor for type '$TYPE_NAME' is not an object"
449
+
450
+ # "a descriptor never carries any other key" — registry header.
451
+ EXTRA_KEYS="$(jq -r --arg t "$TYPE_NAME" '.types[$t] | keys_unsorted - ["verify", "normalize"] | join(", ")' "$REGISTRY_FILE")"
452
+ [ -z "$EXTRA_KEYS" ] \
453
+ || fail 6 "malformed_descriptor" "descriptor for type '$TYPE_NAME' carries key(s) outside {verify, normalize}: $EXTRA_KEYS"
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Descriptor contract validation — BEFORE the value is looked at, so a
457
+ # malformed descriptor cannot slip through on a value that already conformed.
458
+ # ---------------------------------------------------------------------------
459
+
460
+ VERIFY_TYPE="$(jq -r --arg t "$TYPE_NAME" '.types[$t].verify | type' "$REGISTRY_FILE")"
461
+ case "$VERIFY_TYPE" in
462
+ "null"|"object") : ;;
463
+ *) fail 6 "malformed_descriptor" "type '$TYPE_NAME': 'verify' must be an object or null, got $VERIFY_TYPE" ;;
464
+ esac
465
+
466
+ RULE_KINDS=()
467
+ if [ "$VERIFY_TYPE" = "object" ]; then
468
+ assert_no_newlines '.types[$t].verify | keys_unsorted' "the 'verify' rule kinds of type '$TYPE_NAME'"
469
+ read_lines_into RULE_KINDS < <(jq -r --arg t "$TYPE_NAME" '.types[$t].verify | keys_unsorted[]' "$REGISTRY_FILE")
470
+ for kind in ${RULE_KINDS[@]+"${RULE_KINDS[@]}"}; do
471
+ list_contains "$kind" ${REGISTRY_RULE_KINDS[@]+"${REGISTRY_RULE_KINDS[@]}"} \
472
+ || fail 6 "unknown_rule_kind" "type '$TYPE_NAME': verify rule kind '$kind' is not a member of 'verify_rules' ($(join_with_comma ${REGISTRY_RULE_KINDS[@]+"${REGISTRY_RULE_KINDS[@]}"})) in $REGISTRY_FILE"
473
+ list_contains "$kind" "${IMPLEMENTED_RULE_KINDS[@]}" \
474
+ || fail 6 "unimplemented_rule_kind" "type '$TYPE_NAME': verify rule kind '$kind' is enumerated in 'verify_rules' but $SELF implements none of it — adding a rule kind requires code, not only a registry edit (implemented: $(join_with_comma "${IMPLEMENTED_RULE_KINDS[@]}"))"
475
+ done
476
+ fi
477
+
478
+ NORMALIZE_TYPE="$(jq -r --arg t "$TYPE_NAME" '.types[$t].normalize | type' "$REGISTRY_FILE")"
479
+ case "$NORMALIZE_TYPE" in
480
+ "null"|"array") : ;;
481
+ *) fail 6 "malformed_descriptor" "type '$TYPE_NAME': 'normalize' must be an array, got $NORMALIZE_TYPE" ;;
482
+ esac
483
+
484
+ TRANSFORMS=()
485
+ if [ "$NORMALIZE_TYPE" = "array" ]; then
486
+ jq -e --arg t "$TYPE_NAME" '.types[$t].normalize | all(type == "string")' "$REGISTRY_FILE" > /dev/null 2>&1 \
487
+ || fail 6 "malformed_descriptor" "type '$TYPE_NAME': every 'normalize' entry must be a string"
488
+ assert_no_newlines '.types[$t].normalize' "the 'normalize' list of type '$TYPE_NAME'"
489
+ read_lines_into TRANSFORMS < <(jq -r --arg t "$TYPE_NAME" '.types[$t].normalize[]' "$REGISTRY_FILE")
490
+ for transform in ${TRANSFORMS[@]+"${TRANSFORMS[@]}"}; do
491
+ # The identifier check runs FIRST and on the WHOLE entry. A hand-edited
492
+ # "extract the ids the skill meant" must be rejected as that entry, by name,
493
+ # rather than as some token inside it — which is what the registry header
494
+ # means by the format being structurally incapable of carrying a semantic
495
+ # instruction.
496
+ printf '%s\n' "$transform" | grep -Eq -e "$IDENTIFIER_ERE" \
497
+ || fail 6 "malformed_transform_name" "type '$TYPE_NAME': normalize entry '$transform' is not a bare identifier matching $IDENTIFIER_ERE — a free-text or semantic-extraction instruction is not representable in this format"
498
+ list_contains "$transform" ${REGISTRY_TRANSFORMS[@]+"${REGISTRY_TRANSFORMS[@]}"} \
499
+ || fail 6 "unknown_transform" "type '$TYPE_NAME': normalize transform '$transform' is not a member of 'normalize_vocabulary' ($(join_with_comma ${REGISTRY_TRANSFORMS[@]+"${REGISTRY_TRANSFORMS[@]}"})) in $REGISTRY_FILE"
500
+ list_contains "$transform" "${IMPLEMENTED_TRANSFORMS[@]}" \
501
+ || fail 6 "unimplemented_transform" "type '$TYPE_NAME': normalize transform '$transform' is enumerated in 'normalize_vocabulary' but $SELF has no implementation for it — adding a transform requires code, not only a registry edit (implemented: $(join_with_comma "${IMPLEMENTED_TRANSFORMS[@]}"))"
502
+ done
503
+ fi
504
+
505
+ APPLIED_JSON="$(jq -c --arg t "$TYPE_NAME" '.types[$t].normalize // []' "$REGISTRY_FILE")"
506
+ EMPTY_JSON='[]'
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # `verify: null` — always conforming, never failing. Short-circuits here, after
510
+ # the descriptor contract check above and before any line handling, because
511
+ # prose has no checkable shape and no transform could change that verdict.
512
+ # ---------------------------------------------------------------------------
513
+
514
+ if [ "$VERIFY_TYPE" = "null" ]; then
515
+ emit_outcome "conforming" 0 "$(jq -n --arg v "$RAW_VALUE" '$v')" "$EMPTY_JSON" "null"
516
+ fi
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # The value as a list of lines
520
+ # ---------------------------------------------------------------------------
521
+
522
+ ELEMS=()
523
+
524
+ split_into_lines() {
525
+ local s="$1" line
526
+ ELEMS=()
527
+ # The empty value is zero elements, per POSIX line semantics.
528
+ [ -n "$s" ] || return 0
529
+ # A single terminating newline terminates the last line; it does not add an
530
+ # empty one. "a\n" -> [a]; "a\n\n" -> [a, ""].
531
+ s="${s%$'\n'}"
532
+ while :; do
533
+ case "$s" in
534
+ *$'\n'*)
535
+ line="${s%%$'\n'*}"
536
+ ELEMS+=("$line")
537
+ s="${s#*$'\n'}"
538
+ ;;
539
+ *)
540
+ ELEMS+=("$s")
541
+ break
542
+ ;;
543
+ esac
544
+ done
545
+ }
546
+
547
+ join_elems() {
548
+ local out="" i=0
549
+ if [ "${#ELEMS[@]}" -eq 0 ]; then
550
+ printf '%s' ""
551
+ return 0
552
+ fi
553
+ while [ "$i" -lt "${#ELEMS[@]}" ]; do
554
+ if [ "$i" -eq 0 ]; then
555
+ out="${ELEMS[$i]}"
556
+ else
557
+ out="$out
558
+ ${ELEMS[$i]}"
559
+ fi
560
+ i=$((i + 1))
561
+ done
562
+ printf '%s' "$out"
563
+ }
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # Transforms — the closed vocabulary, implemented exactly as the registry
567
+ # header describes it. The `*)` arm is unreachable: an unimplemented name was
568
+ # already rejected above. It stays as a guard against the two lists drifting.
569
+ # ---------------------------------------------------------------------------
570
+
571
+ transform_split_on_comma() {
572
+ local i=0 rest part
573
+ local -a out
574
+ out=()
575
+ while [ "$i" -lt "${#ELEMS[@]}" ]; do
576
+ rest="${ELEMS[$i]}"
577
+ while :; do
578
+ case "$rest" in
579
+ *,*)
580
+ part="${rest%%,*}"
581
+ out+=("$part")
582
+ rest="${rest#*,}"
583
+ ;;
584
+ *)
585
+ out+=("$rest")
586
+ break
587
+ ;;
588
+ esac
589
+ done
590
+ i=$((i + 1))
591
+ done
592
+ ELEMS=()
593
+ if [ "${#out[@]}" -gt 0 ]; then
594
+ ELEMS=("${out[@]}")
595
+ fi
596
+ }
597
+
598
+ # Strips leading and trailing whitespace from one element.
599
+ trim_one() {
600
+ local s="$1"
601
+ s="${s#"${s%%[![:space:]]*}"}"
602
+ s="${s%"${s##*[![:space:]]}"}"
603
+ printf '%s' "$s"
604
+ }
605
+
606
+ transform_trim() {
607
+ local i=0
608
+ local -a out
609
+ out=()
610
+ while [ "$i" -lt "${#ELEMS[@]}" ]; do
611
+ out+=("$(trim_one "${ELEMS[$i]}")")
612
+ i=$((i + 1))
613
+ done
614
+ ELEMS=()
615
+ if [ "${#out[@]}" -gt 0 ]; then
616
+ ELEMS=("${out[@]}")
617
+ fi
618
+ }
619
+
620
+ # "removes elements that are empty after trimming" — registry header. It drops
621
+ # whitespace-only elements without itself rewriting the survivors, so its
622
+ # behaviour does not depend on whether `trim` happened to run before it.
623
+ transform_drop_empty() {
624
+ local i=0
625
+ local -a out
626
+ out=()
627
+ while [ "$i" -lt "${#ELEMS[@]}" ]; do
628
+ if [ -n "$(trim_one "${ELEMS[$i]}")" ]; then
629
+ out+=("${ELEMS[$i]}")
630
+ fi
631
+ i=$((i + 1))
632
+ done
633
+ ELEMS=()
634
+ if [ "${#out[@]}" -gt 0 ]; then
635
+ ELEMS=("${out[@]}")
636
+ fi
637
+ }
638
+
639
+ apply_transform() {
640
+ case "$1" in
641
+ split_on_comma) transform_split_on_comma ;;
642
+ trim) transform_trim ;;
643
+ drop_empty) transform_drop_empty ;;
644
+ *) fail 6 "unimplemented_transform" "internal: no implementation dispatched for transform '$1'" ;;
645
+ esac
646
+ }
647
+
648
+ # ---------------------------------------------------------------------------
649
+ # Verification
650
+ # ---------------------------------------------------------------------------
651
+
652
+ FAIL_KIND=""
653
+ FAIL_RULE=""
654
+ FAIL_INDEX=""
655
+ FAIL_ELEMENT=""
656
+
657
+ # Compiled once per rule kind, so an uncompilable pattern is reported as the
658
+ # registry contract violation it is rather than as a failed match.
659
+ assert_ere_compiles() {
660
+ local kind="$1" pattern="$2" rc=0
661
+ printf '' | grep -Eq -e "$pattern" || rc=$?
662
+ if [ "$rc" -gt 1 ]; then
663
+ fail 6 "uncompilable_pattern" "type '$TYPE_NAME': verify rule '$kind' pattern does not compile as a POSIX ERE (grep -E exited $rc): $pattern"
664
+ fi
665
+ }
666
+
667
+ rule_per_line() {
668
+ local pattern="$1" i=0 rc
669
+ while [ "$i" -lt "${#ELEMS[@]}" ]; do
670
+ rc=0
671
+ printf '%s\n' "${ELEMS[$i]}" | grep -Eq -e "$pattern" || rc=$?
672
+ if [ "$rc" -gt 1 ]; then
673
+ fail 6 "uncompilable_pattern" "type '$TYPE_NAME': verify rule 'per_line' pattern does not compile as a POSIX ERE (grep -E exited $rc): $pattern"
674
+ fi
675
+ if [ "$rc" -ne 0 ]; then
676
+ FAIL_KIND="per_line"
677
+ FAIL_RULE="$pattern"
678
+ FAIL_INDEX="$i"
679
+ FAIL_ELEMENT="${ELEMS[$i]}"
680
+ return 1
681
+ fi
682
+ i=$((i + 1))
683
+ done
684
+ return 0
685
+ }
686
+
687
+ # Returns 0 when every rule in the descriptor's `verify` object holds.
688
+ verify_current() {
689
+ local kind pattern value_type
690
+ FAIL_KIND=""
691
+ FAIL_RULE=""
692
+ FAIL_INDEX=""
693
+ FAIL_ELEMENT=""
694
+ for kind in ${RULE_KINDS[@]+"${RULE_KINDS[@]}"}; do
695
+ value_type="$(jq -r --arg t "$TYPE_NAME" --arg k "$kind" '.types[$t].verify[$k] | type' "$REGISTRY_FILE")"
696
+ case "$kind" in
697
+ per_line)
698
+ [ "$value_type" = "string" ] \
699
+ || fail 6 "malformed_rule" "type '$TYPE_NAME': verify rule 'per_line' must be a string pattern, got $value_type"
700
+ pattern="$(jq -r --arg t "$TYPE_NAME" --arg k "$kind" '.types[$t].verify[$k]' "$REGISTRY_FILE")"
701
+ assert_ere_compiles "$kind" "$pattern"
702
+ if ! rule_per_line "$pattern"; then
703
+ return 1
704
+ fi
705
+ ;;
706
+ *)
707
+ fail 6 "unimplemented_rule_kind" "internal: no implementation dispatched for verify rule kind '$kind'"
708
+ ;;
709
+ esac
710
+ done
711
+ return 0
712
+ }
713
+
714
+ # ---------------------------------------------------------------------------
715
+ # The three outcomes
716
+ # ---------------------------------------------------------------------------
717
+
718
+ split_into_lines "$RAW_VALUE"
719
+
720
+ if verify_current; then
721
+ emit_outcome "conforming" 0 "$(jq -n --arg v "$RAW_VALUE" '$v')" "$EMPTY_JSON" "null"
722
+ fi
723
+
724
+ # Remember the as-given failure: it, not the post-normalize one, is the more
725
+ # useful thing to report if normalization does not rescue the value.
726
+ PRE_FAIL_KIND="$FAIL_KIND"
727
+ PRE_FAIL_RULE="$FAIL_RULE"
728
+ PRE_FAIL_INDEX="$FAIL_INDEX"
729
+ PRE_FAIL_ELEMENT="$FAIL_ELEMENT"
730
+
731
+ if [ "${#TRANSFORMS[@]}" -gt 0 ]; then
732
+ for transform in "${TRANSFORMS[@]}"; do
733
+ apply_transform "$transform"
734
+ done
735
+
736
+ if verify_current; then
737
+ emit_outcome "normalized" 2 "$(jq -n --arg v "$(join_elems)" '$v')" "$APPLIED_JSON" "null"
738
+ fi
739
+ fi
740
+
741
+ # Non-conforming. The reason names the type, the failing rule, the element that
742
+ # failed it, and the raw pre-normalization value — everything a caller needs to
743
+ # report the failure without re-deriving any of it.
744
+ if [ "${#TRANSFORMS[@]}" -gt 0 ]; then
745
+ REASON="type '$TYPE_NAME': value does not satisfy verify rule '$FAIL_KIND' (/$FAIL_RULE/) even after normalize [$(join_with_comma "${TRANSFORMS[@]}")]; element $FAIL_INDEX of the normalized value is '$FAIL_ELEMENT' (as given, element $PRE_FAIL_INDEX was '$PRE_FAIL_ELEMENT'); raw value: '$RAW_VALUE'"
746
+ else
747
+ REASON="type '$TYPE_NAME': value does not satisfy verify rule '$PRE_FAIL_KIND' (/$PRE_FAIL_RULE/) and the type declares no normalize transforms; element $PRE_FAIL_INDEX is '$PRE_FAIL_ELEMENT'; raw value: '$RAW_VALUE'"
748
+ fi
749
+
750
+ emit_outcome "non-conforming" 3 "null" "$APPLIED_JSON" "$(jq -n --arg r "$REASON" '$r')"