@jenga-ai/agent 1.2.3 → 1.3.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 (45) hide show
  1. package/README.md +4 -1
  2. package/agents/developer.md +18 -0
  3. package/agents/scrum-master.md +1 -0
  4. package/agents/tester.md +18 -0
  5. package/hooks/on_session_end.sh +27 -0
  6. package/lib/commands/init.js +23 -68
  7. package/lib/generate-copilot-instructions.js +142 -0
  8. package/package.json +18 -17
  9. package/scripts/postinstall.js +21 -0
  10. package/skills/commit/SKILL.md +11 -1
  11. package/skills/dev-done/SKILL.md +46 -0
  12. package/skills/dev-done/scripts/classify-commit-outcome.sh +114 -0
  13. package/skills/init/SKILL.md +7 -6
  14. package/skills/init/assets/scope-thresholds_template.json +7 -0
  15. package/skills/init/scripts/init.sh +6 -0
  16. package/skills/publish/SKILL.md +66 -0
  17. package/skills/publish/adapters/npm-ci.md +34 -0
  18. package/skills/publish/adapters/npm.md +18 -0
  19. package/skills/publish/assets/ci-contract.md +27 -0
  20. package/skills/publish/assets/publish.example.json +27 -0
  21. package/skills/publish/schemas/publish.schema.json +20 -0
  22. package/skills/publish/scripts/npm_ci_pipeline.sh +29 -0
  23. package/skills/publish/scripts/npm_stage_inspect.sh +829 -0
  24. package/skills/publish/scripts/npm_stage_pipeline.sh +427 -0
  25. package/skills/publish/scripts/publish_common.sh +16 -0
  26. package/skills/publish/scripts/show_history.sh +12 -5
  27. package/skills/publish/scripts/validate_npm_stage_env.sh +184 -0
  28. package/skills/publish/scripts/write_ledger_entry.sh +92 -2
  29. package/skills/reconcile/SKILL.md +121 -11
  30. package/skills/reconcile/assets/report_format.md +17 -0
  31. package/skills/reconcile/scripts/resolve-reconcile-scope.sh +489 -0
  32. package/skills/uncharted/SKILL.md +200 -21
  33. package/skills/uncharted/scripts/directory-triage.sh +342 -0
  34. package/skills/uncharted/scripts/elicitation-state.sh +457 -0
  35. package/templates/SCRUM_BOARD_SCHEMA.md +57 -0
  36. package/templates/agent-context.md.tpl +23 -11
  37. package/templates/copilot-instructions.md.tpl +21 -11
  38. package/mcp/router/README.md +0 -19
  39. package/mcp/router/embedder.js +0 -23
  40. package/mcp/router/index.js +0 -204
  41. package/mcp/router/matcher.js +0 -87
  42. package/mcp/router/package-lock.json +0 -1048
  43. package/mcp/router/package.json +0 -11
  44. package/mcp/router/skill-index.js +0 -104
  45. package/skills/route/SKILL.md +0 -180
@@ -0,0 +1,829 @@
1
+ #!/usr/bin/env bash
2
+ # npm_stage_inspect.sh — `/publish stage`: list/view/download a staged npm
3
+ # release, run the pre-approval smoke test, and approve or reject it.
4
+ #
5
+ # This script is the story's core value: staging without testing just
6
+ # relocates the risk. `test` installs the exact staged tarball into an
7
+ # isolated scratch directory and smoke-tests it; `approve` refuses to run
8
+ # unless a passing test is on record for that exact stage id (or the
9
+ # operator explicitly overrides with a reason).
10
+ #
11
+ # Usage:
12
+ # npm_stage_inspect.sh <sub> [args] [--config <path>] [--dry-run] [--json]
13
+ #
14
+ # Sub-commands:
15
+ # list [<package-spec>] wraps `npm stage list`
16
+ # view <stage-id> wraps `npm stage view`
17
+ # download <stage-id> [--out <dir>] wraps `npm stage download`
18
+ # test <stage-id> [--keep] pre-approval smoke test (see below)
19
+ # approve <stage-id> [--otp <otp>] [--force <reason>]
20
+ # wraps `npm stage approve`, gated
21
+ # by a test interlock
22
+ # reject <stage-id> wraps `npm stage reject` — the
23
+ # discard path for a staged
24
+ # version. npm's own docs page
25
+ # (https://docs.npmjs.com/staged-publishing)
26
+ # omits this command entirely;
27
+ # it is documented here so it is
28
+ # discoverable.
29
+ #
30
+ # `test` — the pre-approval smoke test:
31
+ # 1. Download the staged tarball via `npm stage download <stage-id>`.
32
+ # 2. Create a scratch directory via `mktemp -d`, always OUTSIDE this
33
+ # repository's working tree (asserted explicitly, in addition to
34
+ # `mktemp` already guaranteeing it in any normal environment) — the
35
+ # repo's own node_modules is never touched.
36
+ # 3. `npm init -y`, then `npm install <path-to-tarball>` in the scratch
37
+ # dir — installing the tarball by path, never by registry spec, so the
38
+ # artifact under test is exactly the staged bytes.
39
+ # 4. Run `npm.stage.smoke_cmd` from the matching target's config when set,
40
+ # else the documented default (see `_default_smoke_check` below), which
41
+ # verifies what `scripts/postinstall.js` actually does today: mirrors
42
+ # `skills/` and `agents/` into BOTH `.claude/` and `.agents/` under the
43
+ # consumer root, writes `.jenga-version`, and bootstraps
44
+ # `.github/copilot-instructions.md`. It deliberately does NOT check for
45
+ # `hooks/`, `scripts/`, or `templates/` landing in the project root —
46
+ # commit 619198b already narrowed postinstall's copy set to `skills/`
47
+ # and `agents/` only; those three dirs stay inside
48
+ # `node_modules/@jenga-ai/agent/` and are sourced from there at
49
+ # runtime. See project/documentation/plans/E22_S09_T03-plan.md for the
50
+ # full write-up of this correction relative to an earlier, stale
51
+ # description of postinstall's behavior.
52
+ # 5. Clean up the scratch directory on both success and failure, unless
53
+ # `--keep` was passed.
54
+ # 6. Write a `stage_tested` ledger entry (stage id + pass/fail `--result`).
55
+ #
56
+ # Exit codes:
57
+ # 0 success (or --dry-run/--help completed cleanly)
58
+ # 3 the underlying `npm stage <sub>` command failed, the tarball install
59
+ # failed, or the smoke test failed
60
+ # 4 usage error, unknown sub-command, or the `approve` test interlock
61
+ # refused (no passing test on record and no `--force <reason>`)
62
+ #
63
+ # Security note: `approve --otp` uses the exact argv-prescan-before-parsing
64
+ # guard documented in npm_stage_pipeline.sh (E22_S09_T02) — command tracing
65
+ # is suspended before OTP is ever assigned to a variable, for the rest of the
66
+ # run, if argv contains --otp and tracing was already active. Captured
67
+ # output is additionally scrubbed of the literal OTP value before it is ever
68
+ # printed. The OTP is never written to the ledger, never printed, and never
69
+ # appears in --json output.
70
+
71
+ set -euo pipefail
72
+
73
+ EXIT_OK=0
74
+ EXIT_OP_FAILED=3
75
+ EXIT_USAGE=4
76
+
77
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
78
+
79
+ if REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel 2>/dev/null)"; then
80
+ :
81
+ else
82
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
83
+ fi
84
+
85
+ # shellcheck source=publish_common.sh
86
+ source "${SCRIPT_DIR}/publish_common.sh"
87
+
88
+ WRITE_LEDGER_SCRIPT="${SCRIPT_DIR}/write_ledger_entry.sh"
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # OTP tracing guard — MUST run at the top level, before `main "$@"` is ever
92
+ # called, and before any argv containing --otp is passed to a function.
93
+ #
94
+ # This script dispatches through main() -> cmd_approve() rather than being
95
+ # one flat top-level body (unlike npm_stage_pipeline.sh, E22_S09_T02, whose
96
+ # guard lives at its own top level with nothing to dispatch through). That
97
+ # dispatch is exactly why the guard cannot simply live inside cmd_approve as
98
+ # a narrower, subcommand-scoped check: when this script is invoked as
99
+ # `bash -x npm_stage_inspect.sh approve <id> --otp <value>`, bash's xtrace
100
+ # prints the *expanded arguments of each function-call site* —
101
+ # `+ main approve <id> ... --otp <value>` and, inside main's body,
102
+ # `+ cmd_approve <id> ... --otp <value>` — the moment each call is made,
103
+ # before that function's own body (and any guard living inside it) ever
104
+ # executes. Verified empirically: an OTP guard placed inside cmd_approve
105
+ # left both of those call-site trace lines carrying the literal OTP in
106
+ # `bash -x` output, even though the guard correctly redacted everything
107
+ # printed by the function bodies themselves. Suspending tracing here, before
108
+ # `main "$@"` is invoked at all, prevents both call-site trace lines from
109
+ # ever being emitted in the first place.
110
+ #
111
+ # A narrower window — e.g. suspending tracing only around the `npm stage
112
+ # approve` call itself — is NOT sufficient for the same reason documented in
113
+ # npm_stage_pipeline.sh: xtrace prints the *expanded* value of a variable
114
+ # for every subsequent line that references it. The only reliable guard is
115
+ # to suspend tracing before OTP is ever assigned to a variable (or passed
116
+ # into a traced function call) and never re-enable it for the rest of the
117
+ # run.
118
+ # ---------------------------------------------------------------------------
119
+ _OTP_PRESENT_IN_ARGV=0
120
+ for _otp_scan_arg in "$@"; do
121
+ case "${_otp_scan_arg}" in
122
+ --otp|--otp=*)
123
+ _OTP_PRESENT_IN_ARGV=1
124
+ ;;
125
+ esac
126
+ done
127
+ unset _otp_scan_arg
128
+ if (( _OTP_PRESENT_IN_ARGV )); then
129
+ case "$-" in
130
+ *x*) set +x ;;
131
+ esac
132
+ fi
133
+
134
+ usage() {
135
+ cat <<'USAGE'
136
+ Usage: npm_stage_inspect.sh <sub> [args] [--config <path>] [--dry-run] [--json]
137
+
138
+ Sub-commands:
139
+ list [<package-spec>] List staged versions (readable
140
+ table by default; raw JSON under
141
+ --json)
142
+ view <stage-id> Show a staged version's details
143
+ download <stage-id> [--out <dir>] Download the staged tarball to
144
+ <dir> (default: a fresh scratch
145
+ directory, reported on success)
146
+ test <stage-id> [--keep] Install the staged tarball into
147
+ an isolated scratch directory
148
+ outside the repo and smoke-test
149
+ it; writes a stage_tested ledger
150
+ entry. --keep retains the scratch
151
+ dir for debugging.
152
+ approve <stage-id> [--otp <otp>] [--force <reason>]
153
+ Approve a staged version for
154
+ release. Refuses without a
155
+ passing `test` on record for this
156
+ exact stage id, unless --force
157
+ <reason> is given. --otp is
158
+ passed through when supplied;
159
+ npm prompts interactively
160
+ otherwise.
161
+ reject <stage-id> Discard a staged version — the
162
+ discard path omitted from npm's
163
+ own staged-publishing docs page.
164
+
165
+ Global flags (accepted by every sub-command):
166
+ --config <path> Path to publish.json (default: <repo-root>/publish.json,
167
+ then project/configs/publish.json)
168
+ --dry-run Print the resolved npm command(s)/steps instead of
169
+ running them; makes no registry-mutating call
170
+ --json Emit a machine-readable JSON summary instead of a
171
+ human-readable one (list/view pass through npm's own
172
+ --json output verbatim)
173
+ USAGE
174
+ }
175
+
176
+ fail_usage() {
177
+ usage >&2
178
+ printf 'npm stage inspect error: %s\n' "$1" >&2
179
+ exit "${EXIT_USAGE}"
180
+ }
181
+
182
+ log_info() { printf '→ %s\n' "$1"; }
183
+ log_warn() { printf '⚠ %s\n' "$1" >&2; }
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Config resolution — mirrors validate_npm_stage_env.sh's search order.
187
+ # Returns non-zero (empty stdout) rather than failing the script outright:
188
+ # list/view/download/reject can all operate without a config at all, and
189
+ # test/approve fall back to documented defaults when unresolved.
190
+ # ---------------------------------------------------------------------------
191
+ resolve_config_path() {
192
+ local explicit="$1"
193
+ if [[ -n "${explicit}" ]]; then
194
+ publish_normalize_path "${explicit}"
195
+ return 0
196
+ fi
197
+ if [[ -f "${REPO_ROOT}/publish.json" ]]; then
198
+ printf '%s\n' "${REPO_ROOT}/publish.json"
199
+ return 0
200
+ fi
201
+ if [[ -f "${PUBLISH_DEFAULT_CONFIG_FILE}" ]]; then
202
+ printf '%s\n' "${PUBLISH_DEFAULT_CONFIG_FILE}"
203
+ return 0
204
+ fi
205
+ return 1
206
+ }
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Stage metadata helpers — `npm stage view --json` output shape is an
210
+ # unverifiable-in-this-environment npm CLI surface (npm >= 11.15.0 is very
211
+ # new; no real binary or network access to npm's docs here), same caveat
212
+ # already documented for npm_stage_pipeline.sh's stage-id capture. Parsed
213
+ # defensively with fallback key names; a failed/empty view is tolerated
214
+ # everywhere it's used — callers fall back to documented defaults.
215
+ # ---------------------------------------------------------------------------
216
+ _stage_view_json() {
217
+ npm stage view "$1" --json 2>/dev/null || true
218
+ }
219
+
220
+ _stage_view_field() {
221
+ local view_json="$1"
222
+ shift
223
+ [[ -n "${view_json}" ]] || { printf ''; return; }
224
+ printf '%s' "${view_json}" | jq -e . >/dev/null 2>&1 || { printf ''; return; }
225
+ printf '%s' "${view_json}" | jq -r "$1" 2>/dev/null || true
226
+ }
227
+
228
+ _stage_package_name() {
229
+ _stage_view_field "$1" '.name // .package // .packageName // empty'
230
+ }
231
+
232
+ _stage_package_version() {
233
+ _stage_view_field "$1" '.version // empty'
234
+ }
235
+
236
+ # resolve_stage_target <package-name> <config-path>
237
+ # Prints four tab-separated fields: target_name target_type smoke_cmd
238
+ # require_test_before_approve. Falls back to "unknown"/"npm"/""/"true" when
239
+ # no config, no matching target, or no npm.stage block is found — test and
240
+ # approve must still work against an unconfigured target using documented
241
+ # defaults.
242
+ resolve_stage_target() {
243
+ local package_name="$1" config_path="$2"
244
+ local target_name="unknown" target_type="npm" smoke_cmd="" require_test="true"
245
+
246
+ if [[ -n "${config_path}" && -f "${config_path}" ]] && command -v jq >/dev/null 2>&1; then
247
+ local target_json=""
248
+ if [[ -n "${package_name}" ]]; then
249
+ target_json="$(jq -c --arg pkg "${package_name}" \
250
+ '.targets[]? | select((.type == "npm" or .type == "npm-ci") and (.npm.package_name // "") == $pkg)' \
251
+ "${config_path}" 2>/dev/null | head -n 1)"
252
+ fi
253
+ if [[ -z "${target_json}" ]]; then
254
+ # No package-name match (or view failed) — fall back to the first
255
+ # configured npm/npm-ci target that has a stage block, so the common
256
+ # single-target setup still resolves.
257
+ target_json="$(jq -c '.targets[]? | select((.type == "npm" or .type == "npm-ci") and (.npm.stage // null) != null)' \
258
+ "${config_path}" 2>/dev/null | head -n 1)"
259
+ fi
260
+ if [[ -z "${target_json}" ]]; then
261
+ target_json="$(jq -c '.targets[]? | select(.type == "npm" or .type == "npm-ci")' \
262
+ "${config_path}" 2>/dev/null | head -n 1)"
263
+ fi
264
+ if [[ -n "${target_json}" ]]; then
265
+ target_name="$(printf '%s' "${target_json}" | jq -r '.name // "unknown"')"
266
+ target_type="$(printf '%s' "${target_json}" | jq -r '.type // "npm"')"
267
+ smoke_cmd="$(printf '%s' "${target_json}" | jq -r '.npm.stage.smoke_cmd // empty')"
268
+ require_test="$(printf '%s' "${target_json}" | jq -r '(.npm.stage.require_test_before_approve // true)')"
269
+ fi
270
+ fi
271
+
272
+ # A plain tab is unsuitable as the field delimiter here: bash's `read`
273
+ # treats tab as "IFS whitespace" regardless of what IFS is set to, which
274
+ # collapses consecutive delimiters and silently drops empty fields (e.g.
275
+ # an unset smoke_cmd) rather than preserving them as empty. The ASCII
276
+ # Unit Separator (0x1F) is not treated specially and is never expected to
277
+ # appear in any of these values.
278
+ printf '%s\x1f%s\x1f%s\x1f%s\n' "${target_name}" "${target_type}" "${smoke_cmd}" "${require_test}"
279
+ }
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Tarball download — shared by `download` and `test`. Since the exact output
283
+ # shape/naming convention of `npm stage download` is unverifiable in this
284
+ # environment, this snapshots the destination directory's file listing
285
+ # before and after running the command (with cwd set to the destination, so
286
+ # wherever the tarball lands, it lands inside it) and diffs to find the new
287
+ # file — robust regardless of naming convention.
288
+ # ---------------------------------------------------------------------------
289
+ _download_stage_tarball() {
290
+ local stage_id="$1" dest_dir="$2"
291
+ mkdir -p "${dest_dir}"
292
+ dest_dir="$(cd "${dest_dir}" && pwd)"
293
+
294
+ local before after new_file=""
295
+ before="$(find "${dest_dir}" -maxdepth 1 -type f 2>/dev/null | sort)"
296
+
297
+ local output status=0
298
+ output="$(cd "${dest_dir}" && npm stage download "${stage_id}" 2>&1)" || status=$?
299
+ if [[ ${status} -ne 0 ]]; then
300
+ printf '%s\n' "${output}" >&2
301
+ printf 'npm stage inspect: npm stage download failed (exit %s).\n' "${status}" >&2
302
+ return "${EXIT_OP_FAILED}"
303
+ fi
304
+
305
+ after="$(find "${dest_dir}" -maxdepth 1 -type f 2>/dev/null | sort)"
306
+ new_file="$(comm -13 <(printf '%s\n' "${before}") <(printf '%s\n' "${after}") | head -n 1)"
307
+
308
+ if [[ -z "${new_file}" ]]; then
309
+ new_file="$(printf '%s\n' "${output}" | grep -Eo '[^[:space:]"'"'"']+\.tgz' | head -n 1)"
310
+ if [[ -n "${new_file}" && "${new_file}" != /* ]]; then
311
+ new_file="${dest_dir}/${new_file}"
312
+ fi
313
+ fi
314
+
315
+ if [[ -z "${new_file}" || ! -f "${new_file}" ]]; then
316
+ printf 'npm stage inspect: could not locate the downloaded tarball in %s after "npm stage download %s". npm output:\n%s\n' \
317
+ "${dest_dir}" "${stage_id}" "${output}" >&2
318
+ return "${EXIT_OP_FAILED}"
319
+ fi
320
+
321
+ printf '%s\n' "${new_file}"
322
+ }
323
+
324
+ # _installed_package_info <scratch-dir>
325
+ # Prints "<name>\x1f<version>" for the single dependency npm installed from
326
+ # the tarball into a fresh `npm init -y` scratch project — the ground-truth
327
+ # name/version actually installed (read from the scratch project's own
328
+ # package.json "dependencies" key, then the installed package's own
329
+ # package.json), independent of whatever `npm stage view` reports. Uses the
330
+ # ASCII Unit Separator (0x1F) as the field delimiter, not a tab — see the
331
+ # comment in resolve_stage_target for why a tab silently drops empty fields
332
+ # under bash's `read`.
333
+ _installed_package_info() {
334
+ local scratch_dir="$1"
335
+ local dep_name
336
+ dep_name="$(jq -r '.dependencies // {} | keys[0] // empty' "${scratch_dir}/package.json" 2>/dev/null || true)"
337
+ if [[ -z "${dep_name}" ]]; then
338
+ printf '\x1f\n'
339
+ return
340
+ fi
341
+ local pkg_json="${scratch_dir}/node_modules/${dep_name}/package.json"
342
+ if [[ ! -f "${pkg_json}" ]]; then
343
+ printf '%s\x1f\n' "${dep_name}"
344
+ return
345
+ fi
346
+ local version
347
+ version="$(jq -r '.version // empty' "${pkg_json}" 2>/dev/null || true)"
348
+ printf '%s\x1f%s\n' "${dep_name}" "${version}"
349
+ }
350
+
351
+ # _default_smoke_check <scratch-dir>
352
+ # See the header comment for why this checks .claude/.agents mirroring
353
+ # rather than a project-root copy of hooks/scripts/templates.
354
+ _default_smoke_check() {
355
+ local scratch_dir="$1"
356
+ local -a missing=()
357
+ local dir
358
+
359
+ for dir in .claude/skills .claude/agents .agents/skills .agents/agents; do
360
+ if [[ ! -d "${scratch_dir}/${dir}" ]] || [[ -z "$(ls -A "${scratch_dir}/${dir}" 2>/dev/null)" ]]; then
361
+ missing+=("${dir}/ (missing or empty)")
362
+ fi
363
+ done
364
+
365
+ [[ -f "${scratch_dir}/.jenga-version" ]] || missing+=(".jenga-version (not written)")
366
+ [[ -f "${scratch_dir}/.github/copilot-instructions.md" ]] || missing+=(".github/copilot-instructions.md (not bootstrapped)")
367
+
368
+ if (( ${#missing[@]} > 0 )); then
369
+ printf 'default smoke check FAILED — postinstall did not produce the expected layout under %s:\n' "${scratch_dir}"
370
+ printf ' - %s\n' "${missing[@]}"
371
+ return 1
372
+ fi
373
+
374
+ printf 'default smoke check passed — postinstall populated .claude/{skills,agents}, .agents/{skills,agents}, .jenga-version, and .github/copilot-instructions.md under %s\n' "${scratch_dir}"
375
+ }
376
+
377
+ # _cleanup_scratch_dir <scratch-dir> <keep-flag>
378
+ # Invoked from an EXIT trap registered by cmd_test, with both arguments
379
+ # baked into the trap command string at registration time (see the comment
380
+ # at the trap registration site for why — dynamic-scope lookup of the
381
+ # calling function's `local`s does not survive `exit` unwinding the call
382
+ # stack before traps run).
383
+ _cleanup_scratch_dir() {
384
+ local dir="$1" keep_flag="$2"
385
+ if [[ "${keep_flag}" == "1" ]]; then
386
+ log_warn "--keep set: leaving scratch dir at ${dir}"
387
+ return
388
+ fi
389
+ rm -rf "${dir}"
390
+ }
391
+
392
+ _write_stage_tested_ledger() {
393
+ local stage_id="$1" target_name="$2" target_type="$3" config_path="$4" result="$5" version="$6"
394
+ local -a cmd=(bash "${WRITE_LEDGER_SCRIPT}" "${target_name}" "${target_type}" stage_tested "" --stage-id "${stage_id}" --result "${result}")
395
+ [[ -n "${config_path}" ]] && cmd+=(--config "${config_path}")
396
+ [[ -n "${version}" ]] && cmd+=(--version "${version}")
397
+ "${cmd[@]}" || log_warn "failed to write 'stage_tested' ledger entry for stage ${stage_id} (result: ${result})"
398
+ }
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # list [<package-spec>] [--config <path>] [--dry-run] [--json]
402
+ # ---------------------------------------------------------------------------
403
+ cmd_list() {
404
+ local config_arg="" dry_run=0 json_out=0
405
+ local -a positional=()
406
+ while [[ $# -gt 0 ]]; do
407
+ case "$1" in
408
+ --config) [[ $# -ge 2 ]] || fail_usage "--config requires a value"; config_arg="$2"; shift 2 ;;
409
+ --dry-run) dry_run=1; shift ;;
410
+ --json) json_out=1; shift ;;
411
+ -h|--help) usage; exit "${EXIT_OK}" ;;
412
+ -*) fail_usage "list: unknown flag: $1" ;;
413
+ *) positional+=("$1"); shift ;;
414
+ esac
415
+ done
416
+ [[ ${#positional[@]} -le 1 ]] || fail_usage "list: too many arguments"
417
+ local package_spec="${positional[0]:-}"
418
+ # config_arg is accepted for interface consistency across sub-commands;
419
+ # `list` itself needs no config (npm stage list takes no target/config).
420
+ : "${config_arg}"
421
+
422
+ local -a cmd=(npm stage list)
423
+ [[ -n "${package_spec}" ]] && cmd+=("${package_spec}")
424
+ cmd+=(--json)
425
+
426
+ if (( dry_run )); then
427
+ log_info "[dry-run] resolved command: $(printf '%q ' "${cmd[@]}")"
428
+ exit "${EXIT_OK}"
429
+ fi
430
+
431
+ local output status=0
432
+ output="$("${cmd[@]}" 2>&1)" || status=$?
433
+ if [[ ${status} -ne 0 ]]; then
434
+ printf '%s\n' "${output}" >&2
435
+ printf 'npm stage inspect: npm stage list failed (exit %s).\n' "${status}" >&2
436
+ exit "${EXIT_OP_FAILED}"
437
+ fi
438
+
439
+ if (( json_out )); then
440
+ printf '%s\n' "${output}"
441
+ exit "${EXIT_OK}"
442
+ fi
443
+
444
+ if ! printf '%s' "${output}" | jq -e . >/dev/null 2>&1; then
445
+ printf '%s\n' "${output}"
446
+ exit "${EXIT_OK}"
447
+ fi
448
+
449
+ printf '%-24s %-30s %-10s %-10s %s\n' 'STAGE ID' 'PACKAGE' 'VERSION' 'DIST-TAG' 'STAGED AT'
450
+ printf '%-24s %-30s %-10s %-10s %s\n' '--------' '-------' '-------' '--------' '---------'
451
+ printf '%s' "${output}" | jq -r '
452
+ ( if (type == "array") then . else (.stages? // .items? // []) end ) as $entries
453
+ | $entries[]?
454
+ | [ (.id // .stageId // .stage_id // "-"),
455
+ (.name // .package // .packageName // "-"),
456
+ (.version // "-"),
457
+ (.tag // .distTag // .dist_tag // "-"),
458
+ (.stagedAt // .staged_at // .created // .createdAt // "-") ]
459
+ | @tsv
460
+ ' | while IFS=$'\t' read -r sid pkg ver tag staged; do
461
+ printf '%-24s %-30s %-10s %-10s %s\n' "${sid}" "${pkg}" "${ver}" "${tag}" "${staged}"
462
+ done
463
+ }
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # view <stage-id> [--config <path>] [--dry-run] [--json]
467
+ # ---------------------------------------------------------------------------
468
+ cmd_view() {
469
+ local config_arg="" dry_run=0 json_out=0
470
+ local -a positional=()
471
+ while [[ $# -gt 0 ]]; do
472
+ case "$1" in
473
+ --config) [[ $# -ge 2 ]] || fail_usage "--config requires a value"; config_arg="$2"; shift 2 ;;
474
+ --dry-run) dry_run=1; shift ;;
475
+ --json) json_out=1; shift ;;
476
+ -h|--help) usage; exit "${EXIT_OK}" ;;
477
+ -*) fail_usage "view: unknown flag: $1" ;;
478
+ *) positional+=("$1"); shift ;;
479
+ esac
480
+ done
481
+ [[ ${#positional[@]} -ge 1 ]] || fail_usage "view: <stage-id> is required"
482
+ [[ ${#positional[@]} -le 1 ]] || fail_usage "view: too many arguments"
483
+ local stage_id="${positional[0]}"
484
+ : "${config_arg}"
485
+
486
+ local -a cmd=(npm stage view "${stage_id}")
487
+ (( json_out )) && cmd+=(--json)
488
+
489
+ if (( dry_run )); then
490
+ log_info "[dry-run] resolved command: $(printf '%q ' "${cmd[@]}")"
491
+ exit "${EXIT_OK}"
492
+ fi
493
+
494
+ local output status=0
495
+ output="$("${cmd[@]}" 2>&1)" || status=$?
496
+ printf '%s\n' "${output}"
497
+ if [[ ${status} -ne 0 ]]; then
498
+ printf 'npm stage inspect: npm stage view failed (exit %s).\n' "${status}" >&2
499
+ exit "${EXIT_OP_FAILED}"
500
+ fi
501
+ }
502
+
503
+ # ---------------------------------------------------------------------------
504
+ # download <stage-id> [--out <dir>] [--config <path>] [--dry-run] [--json]
505
+ # ---------------------------------------------------------------------------
506
+ cmd_download() {
507
+ local config_arg="" dry_run=0 json_out=0 out_dir=""
508
+ local -a positional=()
509
+ while [[ $# -gt 0 ]]; do
510
+ case "$1" in
511
+ --config) [[ $# -ge 2 ]] || fail_usage "--config requires a value"; config_arg="$2"; shift 2 ;;
512
+ --dry-run) dry_run=1; shift ;;
513
+ --json) json_out=1; shift ;;
514
+ --out) [[ $# -ge 2 ]] || fail_usage "--out requires a value"; out_dir="$2"; shift 2 ;;
515
+ -h|--help) usage; exit "${EXIT_OK}" ;;
516
+ -*) fail_usage "download: unknown flag: $1" ;;
517
+ *) positional+=("$1"); shift ;;
518
+ esac
519
+ done
520
+ [[ ${#positional[@]} -ge 1 ]] || fail_usage "download: <stage-id> is required"
521
+ [[ ${#positional[@]} -le 1 ]] || fail_usage "download: too many arguments"
522
+ local stage_id="${positional[0]}"
523
+ : "${config_arg}"
524
+
525
+ if (( dry_run )); then
526
+ log_info "[dry-run] resolved command: npm stage download $(printf '%q' "${stage_id}") (cwd: ${out_dir:-<a fresh mktemp -d>})"
527
+ exit "${EXIT_OK}"
528
+ fi
529
+
530
+ if [[ -z "${out_dir}" ]]; then
531
+ out_dir="$(mktemp -d "${TMPDIR:-/tmp}/npm-stage-download.XXXXXX")"
532
+ fi
533
+
534
+ local tarball_path
535
+ tarball_path="$(_download_stage_tarball "${stage_id}" "${out_dir}")" || exit "${EXIT_OP_FAILED}"
536
+
537
+ if (( json_out )); then
538
+ jq -cn --arg stage_id "${stage_id}" --arg path "${tarball_path}" '{stage_id: $stage_id, tarball_path: $path}'
539
+ else
540
+ printf 'Downloaded stage %s to %s\n' "${stage_id}" "${tarball_path}"
541
+ fi
542
+ }
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # test <stage-id> [--keep] [--config <path>] [--dry-run] [--json]
546
+ # ---------------------------------------------------------------------------
547
+ cmd_test() {
548
+ local config_arg="" dry_run=0 json_out=0 keep=0
549
+ local -a positional=()
550
+ while [[ $# -gt 0 ]]; do
551
+ case "$1" in
552
+ --config) [[ $# -ge 2 ]] || fail_usage "--config requires a value"; config_arg="$2"; shift 2 ;;
553
+ --dry-run) dry_run=1; shift ;;
554
+ --json) json_out=1; shift ;;
555
+ --keep) keep=1; shift ;;
556
+ -h|--help) usage; exit "${EXIT_OK}" ;;
557
+ -*) fail_usage "test: unknown flag: $1" ;;
558
+ *) positional+=("$1"); shift ;;
559
+ esac
560
+ done
561
+ [[ ${#positional[@]} -ge 1 ]] || fail_usage "test: <stage-id> is required"
562
+ [[ ${#positional[@]} -le 1 ]] || fail_usage "test: too many arguments"
563
+ local stage_id="${positional[0]}"
564
+
565
+ local config_path=""
566
+ config_path="$(resolve_config_path "${config_arg}" || true)"
567
+
568
+ local view_json package_name_hint
569
+ view_json="$(_stage_view_json "${stage_id}")"
570
+ package_name_hint="$(_stage_package_name "${view_json}")"
571
+
572
+ local target_name target_type smoke_cmd require_test
573
+ IFS=$'\x1f' read -r target_name target_type smoke_cmd require_test < <(resolve_stage_target "${package_name_hint}" "${config_path}")
574
+
575
+ if (( dry_run )); then
576
+ log_info "[dry-run] would: download stage ${stage_id}; npm init -y && npm install <tarball> in a mktemp -d scratch dir outside ${REPO_ROOT}; then run: ${smoke_cmd:-<documented default smoke check>}"
577
+ exit "${EXIT_OK}"
578
+ fi
579
+
580
+ local scratch_dir
581
+ scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/npm-stage-test.XXXXXX")"
582
+
583
+ # Defense in depth: mktemp already guarantees a location outside the repo
584
+ # working tree in any normal environment; assert it explicitly so a
585
+ # misconfigured TMPDIR can never silently install into (or overwrite) the
586
+ # repo's own node_modules.
587
+ local scratch_real repo_real
588
+ scratch_real="$(cd "${scratch_dir}" && pwd -P)"
589
+ repo_real="$(cd "${REPO_ROOT}" && pwd -P)"
590
+ case "${scratch_real}" in
591
+ "${repo_real}"|"${repo_real}"/*)
592
+ printf 'npm stage inspect: refusing to run — scratch dir %s resolved inside the repository (%s). Check TMPDIR.\n' "${scratch_real}" "${repo_real}" >&2
593
+ rm -rf "${scratch_dir}"
594
+ exit "${EXIT_OP_FAILED}"
595
+ ;;
596
+ esac
597
+
598
+ # Register cleanup via a top-level function invoked with explicit
599
+ # arguments baked into the trap command string at registration time —
600
+ # NOT a closure referencing cmd_test's `local $scratch_dir`/`$keep`
601
+ # directly. `exit` unwinds the function call stack (popping `local`
602
+ # bindings) before running EXIT traps, so a trap body that reads those
603
+ # names by dynamic scope sees them as unset once triggered; passing the
604
+ # values as positional arguments sidesteps that entirely.
605
+ # shellcheck disable=SC2064 # intentional: expand now, not at signal time
606
+ trap "_cleanup_scratch_dir $(printf '%q' "${scratch_dir}") $(printf '%q' "${keep}")" EXIT
607
+
608
+ log_info "[test] downloading stage ${stage_id}..."
609
+ local tarball_path
610
+ tarball_path="$(_download_stage_tarball "${stage_id}" "${scratch_dir}")" || exit "${EXIT_OP_FAILED}"
611
+
612
+ log_info "[test] installing tarball into isolated scratch project at ${scratch_dir}..."
613
+ local install_output install_status=0
614
+ install_output="$(cd "${scratch_dir}" && npm init -y >/dev/null 2>&1 && npm install "${tarball_path}" 2>&1)" || install_status=$?
615
+ if [[ ${install_status} -ne 0 ]]; then
616
+ printf '%s\n' "${install_output}" >&2
617
+ printf 'npm stage inspect: tarball install failed (exit %s).\n' "${install_status}" >&2
618
+ _write_stage_tested_ledger "${stage_id}" "${target_name}" "${target_type}" "${config_path}" fail ""
619
+ exit "${EXIT_OP_FAILED}"
620
+ fi
621
+
622
+ local installed_name installed_version
623
+ IFS=$'\x1f' read -r installed_name installed_version < <(_installed_package_info "${scratch_dir}")
624
+ [[ -n "${installed_version}" ]] || installed_version="$(_stage_package_version "${view_json}")"
625
+
626
+ local smoke_status=0 smoke_output=""
627
+ if [[ -n "${smoke_cmd}" ]]; then
628
+ log_info "[test] running configured npm.stage.smoke_cmd: ${smoke_cmd}"
629
+ smoke_output="$(cd "${scratch_dir}" && bash -c "${smoke_cmd}" 2>&1)" || smoke_status=$?
630
+ else
631
+ log_info "[test] running default smoke check (postinstall file-copy verification)..."
632
+ smoke_output="$(_default_smoke_check "${scratch_dir}" 2>&1)" || smoke_status=$?
633
+ fi
634
+
635
+ if [[ ${smoke_status} -ne 0 ]]; then
636
+ printf '%s\n' "${smoke_output}" >&2
637
+ printf 'npm stage inspect: smoke test FAILED for stage %s (package %s).\n' "${stage_id}" "${installed_name:-unknown}" >&2
638
+ _write_stage_tested_ledger "${stage_id}" "${target_name}" "${target_type}" "${config_path}" fail "${installed_version}"
639
+ exit "${EXIT_OP_FAILED}"
640
+ fi
641
+
642
+ printf '%s\n' "${smoke_output}"
643
+ log_info "[test] smoke test PASSED for stage ${stage_id} (package ${installed_name:-unknown})."
644
+ _write_stage_tested_ledger "${stage_id}" "${target_name}" "${target_type}" "${config_path}" pass "${installed_version}"
645
+
646
+ if (( json_out )); then
647
+ jq -cn --arg stage_id "${stage_id}" --arg pkg "${installed_name:-}" --arg version "${installed_version:-}" \
648
+ '{stage_id: $stage_id, package: $pkg, version: $version, result: "pass"}'
649
+ fi
650
+ }
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # approve <stage-id> [--otp <otp>] [--force <reason>] [--config <path>] [--dry-run] [--json]
654
+ # ---------------------------------------------------------------------------
655
+ cmd_approve() {
656
+ # The OTP tracing guard runs once, at the top level of this script, before
657
+ # `main "$@"` is ever called — see the comment there for why a guard
658
+ # placed only here (inside cmd_approve) is provably insufficient: bash's
659
+ # xtrace prints the expanded arguments of the `main "$@"` and
660
+ # `cmd_approve "$@"` call sites themselves, before this function's body
661
+ # ever runs.
662
+
663
+ local config_arg="" dry_run=0 json_out=0 otp="" force_reason="" force_given=0
664
+ local -a positional=()
665
+ while [[ $# -gt 0 ]]; do
666
+ case "$1" in
667
+ --config) [[ $# -ge 2 ]] || fail_usage "--config requires a value"; config_arg="$2"; shift 2 ;;
668
+ --dry-run) dry_run=1; shift ;;
669
+ --json) json_out=1; shift ;;
670
+ --otp) [[ $# -ge 2 ]] || fail_usage "--otp requires a value"; otp="$2"; shift 2 ;;
671
+ --otp=*) otp="${1#*=}"; shift ;;
672
+ --force) [[ $# -ge 2 ]] || fail_usage "--force requires a <reason>"; force_given=1; force_reason="$2"; shift 2 ;;
673
+ -h|--help) usage; exit "${EXIT_OK}" ;;
674
+ -*) fail_usage "approve: unknown flag: $1" ;;
675
+ *) positional+=("$1"); shift ;;
676
+ esac
677
+ done
678
+ [[ ${#positional[@]} -ge 1 ]] || fail_usage "approve: <stage-id> is required"
679
+ [[ ${#positional[@]} -le 1 ]] || fail_usage "approve: too many arguments"
680
+ local stage_id="${positional[0]}"
681
+
682
+ if (( force_given )); then
683
+ [[ -n "${force_reason}" ]] || fail_usage "approve: --force requires a non-empty <reason>"
684
+ fi
685
+
686
+ local config_path=""
687
+ config_path="$(resolve_config_path "${config_arg}" || true)"
688
+
689
+ local view_json package_name package_version
690
+ view_json="$(_stage_view_json "${stage_id}")"
691
+ package_name="$(_stage_package_name "${view_json}")"
692
+ package_version="$(_stage_package_version "${view_json}")"
693
+
694
+ local target_name target_type smoke_cmd require_test
695
+ IFS=$'\x1f' read -r target_name target_type smoke_cmd require_test < <(resolve_stage_target "${package_name}" "${config_path}")
696
+ : "${smoke_cmd}"
697
+
698
+ if [[ "${require_test}" == "true" ]] && (( ! force_given )); then
699
+ local history_file
700
+ history_file="$(publish_resolve_history_file "${config_path}")"
701
+ if ! publish_history_has_passing_stage_test "${history_file}" "${stage_id}"; then
702
+ printf 'npm stage inspect: approve refused — stage %s has no passing test on record in %s.\n' "${stage_id}" "${history_file}" >&2
703
+ printf 'Fix: run "npm_stage_inspect.sh test %s" first, or pass --force <reason> to override.\n' "${stage_id}" >&2
704
+ exit "${EXIT_USAGE}"
705
+ fi
706
+ fi
707
+
708
+ local -a exec_cmd=(npm stage approve "${stage_id}")
709
+ local -a display_cmd=("${exec_cmd[@]}")
710
+ if [[ -n "${otp}" ]]; then
711
+ display_cmd+=(--otp '********')
712
+ # Tracing was already suspended for the rest of this run (if it was
713
+ # active) by the guard above, the moment argv was found to contain
714
+ # --otp — before OTP was ever assigned to a variable. Nothing further
715
+ # to do here.
716
+ exec_cmd+=(--otp "${otp}")
717
+ fi
718
+
719
+ if (( dry_run )); then
720
+ log_info "[dry-run] resolved command: $(printf '%q ' "${display_cmd[@]}")"
721
+ exit "${EXIT_OK}"
722
+ fi
723
+
724
+ log_info "[approve] resolved command: $(printf '%q ' "${display_cmd[@]}")"
725
+
726
+ local output status=0
727
+ output="$("${exec_cmd[@]}" 2>&1)" || status=$?
728
+
729
+ # Defensive redaction: strip the literal OTP from captured output before
730
+ # it is ever printed, in case npm echoed the argv back in an error
731
+ # message.
732
+ if [[ -n "${otp}" ]]; then
733
+ output="${output//"${otp}"/********}"
734
+ fi
735
+ printf '%s\n' "${output}"
736
+
737
+ if [[ ${status} -ne 0 ]]; then
738
+ printf 'npm stage inspect: npm stage approve failed (exit %s).\n' "${status}" >&2
739
+ exit "${EXIT_OP_FAILED}"
740
+ fi
741
+
742
+ local -a ledger_cmd=(bash "${WRITE_LEDGER_SCRIPT}" "${target_name}" "${target_type}" approved "" --stage-id "${stage_id}")
743
+ [[ -n "${config_path}" ]] && ledger_cmd+=(--config "${config_path}")
744
+ [[ -n "${package_version}" ]] && ledger_cmd+=(--version "${package_version}")
745
+ (( force_given )) && ledger_cmd+=(--reason "${force_reason}")
746
+ "${ledger_cmd[@]}" || log_warn "failed to write 'approved' ledger entry for stage ${stage_id}"
747
+
748
+ if (( json_out )); then
749
+ jq -cn --arg stage_id "${stage_id}" --arg pkg "${package_name:-}" '{stage_id: $stage_id, package: $pkg, state: "approved"}'
750
+ fi
751
+ }
752
+
753
+ # ---------------------------------------------------------------------------
754
+ # reject <stage-id> [--config <path>] [--dry-run] [--json]
755
+ # ---------------------------------------------------------------------------
756
+ cmd_reject() {
757
+ local config_arg="" dry_run=0 json_out=0
758
+ local -a positional=()
759
+ while [[ $# -gt 0 ]]; do
760
+ case "$1" in
761
+ --config) [[ $# -ge 2 ]] || fail_usage "--config requires a value"; config_arg="$2"; shift 2 ;;
762
+ --dry-run) dry_run=1; shift ;;
763
+ --json) json_out=1; shift ;;
764
+ -h|--help) usage; exit "${EXIT_OK}" ;;
765
+ -*) fail_usage "reject: unknown flag: $1" ;;
766
+ *) positional+=("$1"); shift ;;
767
+ esac
768
+ done
769
+ [[ ${#positional[@]} -ge 1 ]] || fail_usage "reject: <stage-id> is required"
770
+ [[ ${#positional[@]} -le 1 ]] || fail_usage "reject: too many arguments"
771
+ local stage_id="${positional[0]}"
772
+
773
+ local config_path=""
774
+ config_path="$(resolve_config_path "${config_arg}" || true)"
775
+
776
+ local view_json package_name package_version
777
+ view_json="$(_stage_view_json "${stage_id}")"
778
+ package_name="$(_stage_package_name "${view_json}")"
779
+ package_version="$(_stage_package_version "${view_json}")"
780
+
781
+ local target_name target_type smoke_cmd require_test
782
+ IFS=$'\x1f' read -r target_name target_type smoke_cmd require_test < <(resolve_stage_target "${package_name}" "${config_path}")
783
+ : "${smoke_cmd}" "${require_test}"
784
+
785
+ local -a cmd=(npm stage reject "${stage_id}")
786
+
787
+ if (( dry_run )); then
788
+ log_info "[dry-run] resolved command: $(printf '%q ' "${cmd[@]}")"
789
+ exit "${EXIT_OK}"
790
+ fi
791
+
792
+ local output status=0
793
+ output="$("${cmd[@]}" 2>&1)" || status=$?
794
+ printf '%s\n' "${output}"
795
+ if [[ ${status} -ne 0 ]]; then
796
+ printf 'npm stage inspect: npm stage reject failed (exit %s).\n' "${status}" >&2
797
+ exit "${EXIT_OP_FAILED}"
798
+ fi
799
+
800
+ local -a ledger_cmd=(bash "${WRITE_LEDGER_SCRIPT}" "${target_name}" "${target_type}" rejected "" --stage-id "${stage_id}")
801
+ [[ -n "${config_path}" ]] && ledger_cmd+=(--config "${config_path}")
802
+ [[ -n "${package_version}" ]] && ledger_cmd+=(--version "${package_version}")
803
+ "${ledger_cmd[@]}" || log_warn "failed to write 'rejected' ledger entry for stage ${stage_id}"
804
+
805
+ if (( json_out )); then
806
+ jq -cn --arg stage_id "${stage_id}" --arg pkg "${package_name:-}" '{stage_id: $stage_id, package: $pkg, state: "rejected"}'
807
+ fi
808
+ }
809
+
810
+ # ---------------------------------------------------------------------------
811
+ # Dispatch
812
+ # ---------------------------------------------------------------------------
813
+ main() {
814
+ [[ $# -ge 1 ]] || fail_usage "a sub-command is required"
815
+ local sub="$1"
816
+ shift
817
+ case "${sub}" in
818
+ list) cmd_list "$@" ;;
819
+ view) cmd_view "$@" ;;
820
+ download) cmd_download "$@" ;;
821
+ test) cmd_test "$@" ;;
822
+ approve) cmd_approve "$@" ;;
823
+ reject) cmd_reject "$@" ;;
824
+ -h|--help) usage; exit "${EXIT_OK}" ;;
825
+ *) fail_usage "unknown sub-command: '${sub}'" ;;
826
+ esac
827
+ }
828
+
829
+ main "$@"