@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,427 @@
1
+ #!/usr/bin/env bash
2
+ # npm_stage_pipeline.sh — `/publish stage publish`: submit a version to npm's
3
+ # staged-publishing area, gated by the mandatory build/test pre-deploy gates.
4
+ #
5
+ # Six ordered phases — the order is the point, since gates that ran after
6
+ # staging would be verifying nothing:
7
+ # 1. validate — validate_npm_stage_env.sh (T01); abort on non-zero
8
+ # 2. gates — run_gates.sh pre <target> <config> [--non-interactive]
9
+ # (build/test are mandatory, global, non-disableable)
10
+ # 3. pack — resolve <name>@<version>, confirm unless --non-interactive
11
+ # 4. stage — for `npm` targets: `npm stage publish --tag ... --access
12
+ # ... [--otp ...]` run locally. For `npm-ci` targets,
13
+ # `--provenance` needs a GitHub Actions OIDC token that only
14
+ # exists inside an Actions run, so this phase instead
15
+ # dispatches the `stage` job of the target's generated
16
+ # workflow (see npm_ci_pipeline.sh) via `gh workflow run
17
+ # ... -f mode=stage` and waits on it with `gh run watch`.
18
+ # 5. capture — parse the stage id, falling back to `npm stage list --json`
19
+ # 6. ledger — write_ledger_entry.sh ... staged ... --stage-id <id> --dist-tag <tag>
20
+ #
21
+ # Usage:
22
+ # npm_stage_pipeline.sh <target> <path-to-publish.json> [--dry-run] [--non-interactive] [--otp <otp>]
23
+ #
24
+ # Exit codes:
25
+ # 0 success (staged, or --dry-run completed cleanly)
26
+ # 1 user declined the pack confirmation prompt (or no tty was available)
27
+ # 2 a mandatory pre-deploy gate failed; nothing was staged
28
+ # 3 staging failed (locally for `npm`, or the dispatched GitHub Actions
29
+ # run for `npm-ci`), or the stage id could not be captured
30
+ # 4 validation failed (validate_npm_stage_env.sh's own exit code, or bad
31
+ # args/config)
32
+ #
33
+ # Security note: --otp is placed into the exec argv passed directly to `npm`
34
+ # and nowhere else — never interpolated into a printed or logged string. If
35
+ # command tracing (`set -x`) is active when this script starts — inherited
36
+ # via SHELLOPTS/BASH_ENV, or the caller ran `bash -x npm_stage_pipeline.sh
37
+ # ... --otp <otp>` — and an --otp value is present in argv, tracing is
38
+ # disabled before OTP is ever assigned to a variable and stays disabled for
39
+ # the rest of the run (see the "OTP tracing guard" block below for why this
40
+ # has to be the whole run, not just the npm invocation). The captured output
41
+ # of the stage invocation is additionally scrubbed of the literal OTP value
42
+ # defensively, before it is ever printed.
43
+
44
+ set -euo pipefail
45
+
46
+ EXIT_ABORTED=1
47
+ EXIT_GATE_FAILURE=2
48
+ EXIT_STAGE_FAILURE=3
49
+ EXIT_ENV_INVALID=4
50
+
51
+ DEFAULT_REGISTRY="https://registry.npmjs.org"
52
+
53
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
54
+
55
+ if REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel 2>/dev/null)"; then
56
+ :
57
+ else
58
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
59
+ fi
60
+
61
+ # shellcheck source=publish_common.sh
62
+ source "${SCRIPT_DIR}/publish_common.sh"
63
+
64
+ VALIDATE_SCRIPT="${SCRIPT_DIR}/validate_npm_stage_env.sh"
65
+ RUN_GATES_SCRIPT="${SCRIPT_DIR}/run_gates.sh"
66
+ WRITE_LEDGER_SCRIPT="${SCRIPT_DIR}/write_ledger_entry.sh"
67
+
68
+ usage() {
69
+ cat <<'USAGE'
70
+ Usage: npm_stage_pipeline.sh <target> <path-to-publish.json> [--dry-run] [--non-interactive] [--otp <otp>]
71
+
72
+ Phases (in order): validate, gates, pack, stage, capture, ledger.
73
+ USAGE
74
+ }
75
+
76
+ fail_usage() {
77
+ usage >&2
78
+ printf 'npm stage pipeline error: %s\n' "$1" >&2
79
+ exit "${EXIT_ENV_INVALID}"
80
+ }
81
+
82
+ log_info() {
83
+ printf '→ %s\n' "$1"
84
+ }
85
+
86
+ log_warn() {
87
+ printf '⚠ %s\n' "$1" >&2
88
+ }
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # OTP tracing guard — must run before argv is parsed, i.e. before OTP is
92
+ # ever assigned to a variable. If the caller invoked this script with
93
+ # command tracing already enabled (`set -x`, inherited via SHELLOPTS/
94
+ # BASH_ENV, or `bash -x npm_stage_pipeline.sh ... --otp <otp>`) and an
95
+ # --otp value is present anywhere in argv, tracing is disabled immediately
96
+ # and stays disabled for the remainder of this run.
97
+ #
98
+ # A narrower window — e.g. suspending tracing only around the `npm stage
99
+ # publish` call itself and restoring it afterward — is NOT sufficient:
100
+ # bash's xtrace prints the *expanded* value of $OTP for every subsequent
101
+ # line that so much as references the variable. A plain truthiness check
102
+ # like `[[ -n "$OTP" ]]` is traced as `+ [[ -n 123456 ]]`, leaking the code
103
+ # just as surely as printing it directly. The only reliable guard is to
104
+ # suspend tracing before the first assignment and never re-enable it for
105
+ # the rest of the run.
106
+ # ---------------------------------------------------------------------------
107
+ OTP_PRESENT_IN_ARGV=0
108
+ for _arg in "$@"; do
109
+ case "${_arg}" in
110
+ --otp|--otp=*)
111
+ OTP_PRESENT_IN_ARGV=1
112
+ ;;
113
+ esac
114
+ done
115
+ unset _arg
116
+ if (( OTP_PRESENT_IN_ARGV )); then
117
+ case "$-" in
118
+ *x*) set +x ;;
119
+ esac
120
+ fi
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Arg parsing
124
+ # ---------------------------------------------------------------------------
125
+ DRY_RUN=0
126
+ NON_INTERACTIVE=0
127
+ OTP=""
128
+
129
+ POSITIONAL=()
130
+ while (( $# )); do
131
+ case "$1" in
132
+ --dry-run)
133
+ DRY_RUN=1
134
+ shift
135
+ ;;
136
+ --non-interactive)
137
+ NON_INTERACTIVE=1
138
+ shift
139
+ ;;
140
+ --otp)
141
+ [[ $# -ge 2 ]] || fail_usage "--otp requires a value"
142
+ OTP="$2"
143
+ shift 2
144
+ ;;
145
+ --otp=*)
146
+ OTP="${1#*=}"
147
+ shift
148
+ ;;
149
+ -h|--help)
150
+ usage
151
+ exit 0
152
+ ;;
153
+ --)
154
+ shift
155
+ while (( $# )); do
156
+ POSITIONAL+=("$1")
157
+ shift
158
+ done
159
+ ;;
160
+ -*)
161
+ fail_usage "unknown option: $1"
162
+ ;;
163
+ *)
164
+ POSITIONAL+=("$1")
165
+ shift
166
+ ;;
167
+ esac
168
+ done
169
+
170
+ [[ ${#POSITIONAL[@]} -ge 1 ]] || fail_usage "<target> is required"
171
+ TARGET_NAME="${POSITIONAL[0]}"
172
+ [[ ${#POSITIONAL[@]} -ge 2 ]] || fail_usage "<path-to-publish.json> is required"
173
+ CONFIG_PATH_ARG="${POSITIONAL[1]}"
174
+ [[ ${#POSITIONAL[@]} -le 2 ]] || fail_usage "unexpected extra positional arguments"
175
+
176
+ command -v jq >/dev/null 2>&1 || fail_usage "jq is required but not found on PATH"
177
+ command -v npm >/dev/null 2>&1 || fail_usage "npm is required but not found on PATH"
178
+
179
+ CONFIG_PATH="$(publish_normalize_path "${CONFIG_PATH_ARG}")"
180
+ [[ -f "${CONFIG_PATH}" ]] || fail_usage "publish config not found at '${CONFIG_PATH}'"
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Phase 1: validate
184
+ # ---------------------------------------------------------------------------
185
+ log_info "[validate] checking staging environment for target '${TARGET_NAME}'..."
186
+
187
+ VALIDATE_STATUS=0
188
+ bash "${VALIDATE_SCRIPT}" "${TARGET_NAME}" "${CONFIG_PATH}" || VALIDATE_STATUS=$?
189
+ if [[ ${VALIDATE_STATUS} -ne 0 ]]; then
190
+ printf 'npm stage pipeline: environment validation failed (exit %s); nothing staged.\n' "${VALIDATE_STATUS}" >&2
191
+ exit "${VALIDATE_STATUS}"
192
+ fi
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Phase 2: gates — build/test are mandatory global pre-deploy gates for
196
+ # npm/npm-ci targets (enforced inside run_gates.sh itself; not repeated or
197
+ # re-implemented here) and cannot be disabled via target config.
198
+ # ---------------------------------------------------------------------------
199
+ log_info "[gates] running mandatory pre-deploy gates (build, test) for target '${TARGET_NAME}'..."
200
+
201
+ GATES_CMD=(bash "${RUN_GATES_SCRIPT}" pre "${TARGET_NAME}" "${CONFIG_PATH}")
202
+ (( NON_INTERACTIVE )) && GATES_CMD+=(--non-interactive)
203
+
204
+ GATES_STATUS=0
205
+ "${GATES_CMD[@]}" || GATES_STATUS=$?
206
+ if [[ ${GATES_STATUS} -ne 0 ]]; then
207
+ printf 'npm stage pipeline: pre-deploy gates failed; nothing staged.\n' >&2
208
+ exit "${EXIT_GATE_FAILURE}"
209
+ fi
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Phase 3: pack — resolve <name>@<version> and confirm
213
+ # ---------------------------------------------------------------------------
214
+ TARGET_JSON="$(jq -c --arg t "${TARGET_NAME}" '.targets[]? | select(.name == $t)' "${CONFIG_PATH}")"
215
+ [[ -n "${TARGET_JSON}" ]] || fail_usage "target '${TARGET_NAME}' was not found in '${CONFIG_PATH}'"
216
+
217
+ TARGET_TYPE="$(printf '%s' "${TARGET_JSON}" | jq -r '.type // empty')"
218
+ if [[ "${TARGET_TYPE}" != "npm" && "${TARGET_TYPE}" != "npm-ci" ]]; then
219
+ fail_usage "target '${TARGET_NAME}' has type '${TARGET_TYPE}'; staging only supports 'npm' and 'npm-ci'"
220
+ fi
221
+
222
+ PACKAGE_NAME="$(printf '%s' "${TARGET_JSON}" | jq -r '.npm.package_name // empty')"
223
+ [[ -n "${PACKAGE_NAME}" ]] || fail_usage "target '${TARGET_NAME}' is missing required field 'npm.package_name'"
224
+
225
+ NPM_ACCESS="$(printf '%s' "${TARGET_JSON}" | jq -r '.npm.access // empty')"
226
+ [[ -n "${NPM_ACCESS}" ]] || fail_usage "target '${TARGET_NAME}' is missing required field 'npm.access'"
227
+
228
+ DIST_TAG="$(printf '%s' "${TARGET_JSON}" | jq -r '.npm.dist_tag // "latest"')"
229
+ REGISTRY="$(printf '%s' "${TARGET_JSON}" | jq -r --arg d "${DEFAULT_REGISTRY}" '.npm.registry // $d')"
230
+
231
+ PACKAGE_JSON="${REPO_ROOT}/package.json"
232
+ [[ -f "${PACKAGE_JSON}" ]] || fail_usage "package.json not found at '${PACKAGE_JSON}'"
233
+
234
+ PACKAGE_VERSION="$(jq -r '.version // empty' "${PACKAGE_JSON}")"
235
+ [[ -n "${PACKAGE_VERSION}" ]] || fail_usage "package.json is missing a 'version' field"
236
+
237
+ PACKAGE_SPEC="${PACKAGE_NAME}@${PACKAGE_VERSION}"
238
+
239
+ echo "========== NPM STAGE PIPELINE =========="
240
+ printf 'Target: %s (%s)\n' "${TARGET_NAME}" "${TARGET_TYPE}"
241
+ printf 'Package: %s\n' "${PACKAGE_SPEC}"
242
+ printf 'Dist tag: %s\n' "${DIST_TAG}"
243
+ printf 'Access: %s\n' "${NPM_ACCESS}"
244
+ printf 'Registry: %s\n' "${REGISTRY}"
245
+ printf 'Mode: %s\n' "$( (( DRY_RUN )) && echo 'dry-run' || echo 'live stage' )"
246
+ echo "=========================================="
247
+
248
+ if (( NON_INTERACTIVE == 0 )); then
249
+ PACK_CONFIRM_FD=""
250
+ if [[ -t 0 ]] && exec 3</dev/tty 2>/dev/null; then
251
+ PACK_CONFIRM_FD=3
252
+ fi
253
+
254
+ if [[ -z "${PACK_CONFIRM_FD}" ]]; then
255
+ log_warn "no interactive tty available for pack confirmation; treating as declined. Pass --non-interactive to bypass."
256
+ PACK_RESPONSE=""
257
+ else
258
+ printf 'Stage %s to target "%s"? [y/N] ' "${PACKAGE_SPEC}" "${TARGET_NAME}" >&2
259
+ if ! IFS= read -r -u "${PACK_CONFIRM_FD}" PACK_RESPONSE; then
260
+ PACK_RESPONSE=""
261
+ fi
262
+ exec 3<&- 2>/dev/null || true
263
+ fi
264
+
265
+ case "${PACK_RESPONSE:-}" in
266
+ y|Y|yes|YES)
267
+ : ;;
268
+ *)
269
+ printf 'npm stage pipeline: pack confirmation declined; nothing staged.\n' >&2
270
+ exit "${EXIT_ABORTED}"
271
+ ;;
272
+ esac
273
+ else
274
+ log_info "[pack] --non-interactive: skipping confirmation prompt for ${PACKAGE_SPEC}"
275
+ fi
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Phase 4: stage
279
+ #
280
+ # `npm` targets stage locally via `npm stage publish`. `npm-ci` targets pass
281
+ # `--provenance`, which requires a GitHub Actions OIDC token that only
282
+ # exists inside an Actions run — there is no local equivalent — so instead
283
+ # this dispatches the `stage` job of the target's generated workflow (the
284
+ # same workflow file/OIDC Trusted Publisher link `npm_ci_pipeline.sh` uses
285
+ # for `/publish deploy`) via `gh workflow run ... -f mode=stage` and waits
286
+ # for it with `gh run watch`.
287
+ # ---------------------------------------------------------------------------
288
+ STAGE_OUTPUT=""
289
+ STAGE_STATUS=0
290
+
291
+ if [[ "${TARGET_TYPE}" == "npm-ci" ]]; then
292
+ command -v gh >/dev/null 2>&1 || fail_usage "'gh' CLI is required to stage an 'npm-ci' target (staging is dispatched as a GitHub Actions workflow run) but was not found on PATH"
293
+
294
+ GITHUB_REPO="$(printf '%s' "${TARGET_JSON}" | jq -r '.github_repo // empty')"
295
+ [[ -n "${GITHUB_REPO}" ]] || fail_usage "target '${TARGET_NAME}' is missing required field 'github_repo' (needed to dispatch the stage job)"
296
+
297
+ WORKFLOW_PATH="$(printf '%s' "${TARGET_JSON}" | jq -r '.workflow_path // ".github/workflows/npm-publish.yml"')"
298
+ WORKFLOW_FILENAME="$(basename "${WORKFLOW_PATH}")"
299
+
300
+ if (( DRY_RUN )); then
301
+ log_info "[dry-run] would dispatch: gh workflow run ${WORKFLOW_FILENAME} --repo ${GITHUB_REPO} -f mode=stage (tag=${DIST_TAG}, access=${NPM_ACCESS})"
302
+ log_info "[dry-run] no workflow run triggered; no ledger entry written, no registry-mutating call made."
303
+ exit 0
304
+ fi
305
+
306
+ log_info "[stage] dispatching 'stage' job of ${WORKFLOW_FILENAME} on ${GITHUB_REPO}..."
307
+ if ! gh workflow run "${WORKFLOW_FILENAME}" --repo "${GITHUB_REPO}" -f mode=stage; then
308
+ printf 'npm stage pipeline: gh workflow run failed; nothing staged.\n' >&2
309
+ exit "${EXIT_STAGE_FAILURE}"
310
+ fi
311
+
312
+ RUN_ID="$(gh run list --workflow "${WORKFLOW_FILENAME}" --repo "${GITHUB_REPO}" --limit 1 --json databaseId --jq '.[0].databaseId')"
313
+ [[ -n "${RUN_ID}" ]] || fail_usage "dispatched the stage workflow but could not resolve its run id via 'gh run list'"
314
+
315
+ log_info "[stage] waiting on run ${RUN_ID}..."
316
+ gh run watch "${RUN_ID}" --repo "${GITHUB_REPO}" --exit-status || STAGE_STATUS=$?
317
+
318
+ RUN_URL="$(gh run view "${RUN_ID}" --repo "${GITHUB_REPO}" --json url --jq '.url' 2>/dev/null || true)"
319
+ STAGE_OUTPUT="staged via GitHub Actions workflow run: ${RUN_URL}"
320
+ else
321
+ STAGE_CMD_BASE=(npm stage publish --tag "${DIST_TAG}" --access "${NPM_ACCESS}")
322
+ [[ -n "${REGISTRY}" ]] && STAGE_CMD_BASE+=(--registry "${REGISTRY}")
323
+
324
+ DISPLAY_CMD=("${STAGE_CMD_BASE[@]}")
325
+ (( DRY_RUN )) && DISPLAY_CMD+=(--dry-run)
326
+ [[ -n "${OTP}" ]] && DISPLAY_CMD+=(--otp '********')
327
+
328
+ log_info "[stage] resolved command: $(printf '%q ' "${DISPLAY_CMD[@]}")"
329
+
330
+ EXEC_CMD=("${STAGE_CMD_BASE[@]}")
331
+ (( DRY_RUN )) && EXEC_CMD+=(--dry-run)
332
+ # Tracing was already suspended for the rest of this run (if it was
333
+ # active) by the "OTP tracing guard" above, the moment argv was found to
334
+ # contain --otp — before OTP was ever assigned to a variable. Nothing
335
+ # further to do here.
336
+ [[ -n "${OTP}" ]] && EXEC_CMD+=(--otp "${OTP}")
337
+
338
+ STAGE_OUTPUT="$(cd "${REPO_ROOT}" && "${EXEC_CMD[@]}" 2>&1)" || STAGE_STATUS=$?
339
+
340
+ # Defensive redaction: strip the literal OTP from captured output before
341
+ # it is ever printed, in case npm echoed the argv back in an error
342
+ # message. Quoting the pattern operand of ${var//pattern/repl} forces a
343
+ # literal (non-glob) match, so this is safe even if the OTP happens to
344
+ # contain characters that are special to bash's extglob pattern matching.
345
+ if [[ -n "${OTP}" ]]; then
346
+ STAGE_OUTPUT="${STAGE_OUTPUT//"${OTP}"/********}"
347
+ fi
348
+ fi
349
+
350
+ printf '%s\n' "${STAGE_OUTPUT}"
351
+
352
+ if [[ ${STAGE_STATUS} -ne 0 ]]; then
353
+ printf 'npm stage pipeline: staging failed (exit %s); nothing staged.\n' "${STAGE_STATUS}" >&2
354
+ exit "${EXIT_STAGE_FAILURE}"
355
+ fi
356
+
357
+ if (( DRY_RUN )); then
358
+ log_info "[dry-run] npm stage publish --dry-run completed; no ledger entry written, no registry-mutating call made."
359
+ exit 0
360
+ fi
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Phase 5: capture — parse the stage id
364
+ # ---------------------------------------------------------------------------
365
+ log_info "[capture] parsing stage id from stage output..."
366
+
367
+ STAGE_ID=""
368
+
369
+ # Attempt 1: direct-output parsing. Tolerant of label variants npm may use
370
+ # ("stage id:", "stageId:", "stage_id="), case-insensitive.
371
+ STAGE_ID="$(printf '%s\n' "${STAGE_OUTPUT}" \
372
+ | grep -Eio '\bstage[ _-]?id["'"'"']?[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9._-]+' \
373
+ | head -n 1 \
374
+ | grep -Eo '[A-Za-z0-9._-]+$' || true)"
375
+
376
+ # Attempt 2: the captured output is itself JSON (e.g. if npm's stage publish
377
+ # supports --json the way `npm publish --json` does).
378
+ if [[ -z "${STAGE_ID}" ]] && printf '%s' "${STAGE_OUTPUT}" | jq -e . >/dev/null 2>&1; then
379
+ STAGE_ID="$(printf '%s' "${STAGE_OUTPUT}" | jq -r '.id // .stageId // .stage_id // empty' 2>/dev/null || true)"
380
+ fi
381
+
382
+ # Attempt 3: fall back to `npm stage list <package>@<version> --json` and
383
+ # extract the most recent matching entry's id.
384
+ if [[ -z "${STAGE_ID}" ]]; then
385
+ log_warn "could not parse a stage id directly from stage output; falling back to 'npm stage list --json'..."
386
+
387
+ LIST_STATUS=0
388
+ LIST_OUTPUT="$(npm stage list "${PACKAGE_SPEC}" --json 2>&1)" || LIST_STATUS=$?
389
+
390
+ if [[ ${LIST_STATUS} -ne 0 ]]; then
391
+ printf '%s\n' "${LIST_OUTPUT}" >&2
392
+ printf 'npm stage pipeline: staged successfully but stage id capture failed (npm stage list also exited %s).\n' "${LIST_STATUS}" >&2
393
+ exit "${EXIT_STAGE_FAILURE}"
394
+ fi
395
+
396
+ if printf '%s' "${LIST_OUTPUT}" | jq -e . >/dev/null 2>&1; then
397
+ STAGE_ID="$(printf '%s' "${LIST_OUTPUT}" | jq -r --arg pkg "${PACKAGE_NAME}" --arg ver "${PACKAGE_VERSION}" '
398
+ ( if (type == "array") then . else (.stages? // .items? // []) end ) as $entries
399
+ | [ $entries[]? | select(((.name // .package // "") == $pkg) and ((.version // "") == $ver)) ]
400
+ | sort_by(.stagedAt // .staged_at // .created // .createdAt // "")
401
+ | last
402
+ | (.id // .stageId // .stage_id // empty)
403
+ ' 2>/dev/null || true)"
404
+ fi
405
+ fi
406
+
407
+ if [[ -z "${STAGE_ID}" ]]; then
408
+ printf 'npm stage pipeline: staged successfully but the stage id could not be captured from either the direct output or "npm stage list --json". Run "npm stage list %s --json" manually to recover it.\n' "${PACKAGE_SPEC}" >&2
409
+ exit "${EXIT_STAGE_FAILURE}"
410
+ fi
411
+
412
+ echo ""
413
+ echo "=========================================="
414
+ printf ' STAGE ID: %s\n' "${STAGE_ID}"
415
+ echo "=========================================="
416
+ echo ""
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Phase 6: ledger — record the staged entry
420
+ # ---------------------------------------------------------------------------
421
+ log_info "[ledger] recording 'staged' ledger entry for ${PACKAGE_SPEC}..."
422
+
423
+ LEDGER_CMD=(bash "${WRITE_LEDGER_SCRIPT}" "${TARGET_NAME}" "${TARGET_TYPE}" staged "" \
424
+ --version "${PACKAGE_VERSION}" --config "${CONFIG_PATH}" --stage-id "${STAGE_ID}" --dist-tag "${DIST_TAG}")
425
+ "${LEDGER_CMD[@]}"
426
+
427
+ log_info "[done] ${PACKAGE_SPEC} staged as ${STAGE_ID} (dist-tag: ${DIST_TAG}). Awaiting 'npm_stage_inspect.sh test' and approval."
@@ -167,6 +167,22 @@ publish_history_has_version() {
167
167
  jq -e --arg version "$version" 'type == "array" and any(.[]?; (.version? // "") == $version)' "$history_file" >/dev/null 2>&1
168
168
  }
169
169
 
170
+ # publish_history_has_passing_stage_test <history_file> <stage_id>
171
+ #
172
+ # True (exit 0) when the ledger contains at least one `stage_tested` entry
173
+ # for the exact given stage id with result == "pass". Used by the
174
+ # npm_stage_inspect.sh `approve` interlock. A missing/unreadable history
175
+ # file is treated as "no passing test on record" (exit 1), not an error —
176
+ # the caller decides how to report that.
177
+ publish_history_has_passing_stage_test() {
178
+ local history_file="$1"
179
+ local stage_id="$2"
180
+ [[ -f "$history_file" ]] || return 1
181
+ jq -e --arg id "$stage_id" \
182
+ 'type == "array" and any(.[]?; (.platform_state? // "") == "stage_tested" and (.stage_id? // "") == $id and (.result? // "") == "pass")' \
183
+ "$history_file" >/dev/null 2>&1
184
+ }
185
+
170
186
  publish_resolve_last_publish_tag() {
171
187
  local history_file="$1"
172
188
  local ref="${2:-HEAD}"
@@ -87,9 +87,16 @@ if (( JSON_OUTPUT )); then
87
87
  exit 0
88
88
  fi
89
89
 
90
- printf '%-10s %-20s %-20s %-10s %s\n' 'Version' 'Target' 'Date' 'State' 'Tag'
91
- printf '%-10s %-20s %-20s %-10s %s\n' '-------' '------' '----' '-----' '---'
92
- printf '%s\n' "$FILTERED_JSON" | jq -r '.[] | [(.version // "-"), (.target // "-"), (.timestamp // .completed_at // .started_at // "-"), (.platform_state // .state // "-"), (.git_tag // .version // "-")] | @tsv' | \
93
- while IFS=$'\t' read -r version target date state tag; do
94
- printf '%-10s %-20s %-20s %-10s %s\n' "$version" "$target" "$date" "$state" "$tag"
90
+ # Staged-publishing states (`staged`, `stage_tested`) are NOT published — a
91
+ # version sitting there must never read as released. The State column
92
+ # already carries a distinct value for each (vs. `uploaded` for a real
93
+ # release), and the Stage ID column below makes a staged/tested/approved/
94
+ # rejected row visibly distinct at a glance even without reading the State
95
+ # column closely: only staged-publishing entries ever have a non-"-" stage
96
+ # id.
97
+ printf '%-10s %-20s %-20s %-14s %-24s %s\n' 'Version' 'Target' 'Date' 'State' 'Stage ID' 'Tag'
98
+ printf '%-10s %-20s %-20s %-14s %-24s %s\n' '-------' '------' '----' '-----' '--------' '---'
99
+ printf '%s\n' "$FILTERED_JSON" | jq -r '.[] | [(.version // "-"), (.target // "-"), (.timestamp // .completed_at // .started_at // "-"), (.platform_state // .state // "-"), (.stage_id // "-"), (.git_tag // .version // "-")] | @tsv' | \
100
+ while IFS=$'\t' read -r version target date state stage_id tag; do
101
+ printf '%-10s %-20s %-20s %-14s %-24s %s\n' "$version" "$target" "$date" "$state" "$stage_id" "$tag"
95
102
  done
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bash
2
+ # validate_npm_stage_env.sh — Preflight gate for `/publish stage`.
3
+ #
4
+ # npm's staged-publishing workflow (npm CLI >= 11.15.0, Node >= 22.14.0) only exists on
5
+ # recent toolchains, only applies to `npm`/`npm-ci` targets, and only works for a package
6
+ # that has already had at least one non-staged publish. This script checks all four
7
+ # preconditions up front so the rest of the `/publish stage` pipeline never has to discover
8
+ # one of them has failed midway through a staging attempt.
9
+ #
10
+ # Usage:
11
+ # validate_npm_stage_env.sh <target> <path-to-publish.json>
12
+ #
13
+ # Exit codes:
14
+ # 0 all four preflight checks passed
15
+ # 4 one of the checks failed (config/environment invalid), matching
16
+ # skills/publish/assets/ci-contract.md
17
+ #
18
+ # Security note: the registry probe (check 4) never prints npm's raw stdout/stderr. Only
19
+ # messages composed by this script are emitted, so nothing npm writes — which could in
20
+ # principle include auth-related registry response detail — reaches stdout, stderr, or any
21
+ # log this script's caller might capture.
22
+
23
+ set -u
24
+
25
+ EXIT_ENV_INVALID=4
26
+
27
+ NPM_MIN_VERSION="11.15.0"
28
+ NODE_MIN_VERSION="22.14.0"
29
+ DEFAULT_REGISTRY="https://registry.npmjs.org"
30
+
31
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Shared helpers (repo-root resolution, path normalization) — reused rather
35
+ # than re-implemented, per the repo-root-then-project/configs/ convention
36
+ # already established in publish_common.sh.
37
+ # ---------------------------------------------------------------------------
38
+ # shellcheck source=publish_common.sh
39
+ source "${SCRIPT_DIR}/publish_common.sh"
40
+
41
+ fail() {
42
+ printf 'stage env validation failed: %s\n' "$1" >&2
43
+ exit "${EXIT_ENV_INVALID}"
44
+ }
45
+
46
+ usage() {
47
+ cat <<'USAGE'
48
+ Usage: validate_npm_stage_env.sh <target> <path-to-publish.json>
49
+ USAGE
50
+ }
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Semver comparison — component-wise numeric compare, boundary inclusive.
54
+ # A naive string or float compare gets "11.9.0 < 11.15.0" backwards (float
55
+ # compare would read 11.9 > 11.15); comparing each dotted component as a
56
+ # base-10 integer gets it right regardless of leading zeros.
57
+ # ---------------------------------------------------------------------------
58
+ version_ge() {
59
+ local version="$1" min="$2"
60
+ local -a v_parts m_parts
61
+ IFS='.' read -r -a v_parts <<< "$version"
62
+ IFS='.' read -r -a m_parts <<< "$min"
63
+
64
+ local i v m
65
+ for i in 0 1 2; do
66
+ v="${v_parts[i]:-0}"
67
+ m="${m_parts[i]:-0}"
68
+ # Strip any non-digit suffix (pre-release/build metadata) so `10#` never
69
+ # trips over a stray character; default to 0 if nothing numeric remains.
70
+ v="${v//[!0-9]/}"
71
+ m="${m//[!0-9]/}"
72
+ v="${v:-0}"
73
+ m="${m:-0}"
74
+ if ((10#$v > 10#$m)); then
75
+ return 0
76
+ elif ((10#$v < 10#$m)); then
77
+ return 1
78
+ fi
79
+ done
80
+ return 0
81
+ }
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Argument & dependency sanity checks
85
+ # ---------------------------------------------------------------------------
86
+ TARGET_NAME="${1:-}"
87
+ CONFIG_PATH_ARG="${2:-}"
88
+
89
+ if [[ -z "${TARGET_NAME}" ]]; then
90
+ usage >&2
91
+ fail "missing target name argument"
92
+ fi
93
+
94
+ command -v jq >/dev/null 2>&1 || fail "jq is required but not found on PATH"
95
+ command -v npm >/dev/null 2>&1 || fail "'npm' was not found on PATH. Install Node.js (which provides npm) from https://nodejs.org/ or via your package manager, then re-run."
96
+ command -v node >/dev/null 2>&1 || fail "'node' was not found on PATH. Install Node.js from https://nodejs.org/ or via your package manager, then re-run."
97
+
98
+ # Resolve the config path: normalize a supplied relative/absolute path via
99
+ # publish_common.sh, or fall back to the repo-root-then-project/configs/
100
+ # search already used elsewhere in this skill.
101
+ if [[ -n "${CONFIG_PATH_ARG}" ]]; then
102
+ CONFIG_PATH="$(publish_normalize_path "${CONFIG_PATH_ARG}")"
103
+ elif [[ -f "${PUBLISH_REPO_ROOT}/publish.json" ]]; then
104
+ CONFIG_PATH="${PUBLISH_REPO_ROOT}/publish.json"
105
+ elif [[ -f "${PUBLISH_DEFAULT_CONFIG_FILE}" ]]; then
106
+ CONFIG_PATH="${PUBLISH_DEFAULT_CONFIG_FILE}"
107
+ else
108
+ usage >&2
109
+ fail "missing publish.json path argument, and no default config found at '${PUBLISH_REPO_ROOT}/publish.json' or '${PUBLISH_DEFAULT_CONFIG_FILE}'"
110
+ fi
111
+
112
+ [[ -f "${CONFIG_PATH}" ]] || fail "publish config not found at '${CONFIG_PATH}'"
113
+ jq -e . "${CONFIG_PATH}" >/dev/null 2>&1 || fail "publish config at '${CONFIG_PATH}' is not valid JSON"
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Check 1 — npm CLI version >= 11.15.0
117
+ # ---------------------------------------------------------------------------
118
+ NPM_VERSION_RAW="$(npm --version 2>/dev/null)"
119
+ NPM_VERSION="${NPM_VERSION_RAW#v}"
120
+ if [[ -z "${NPM_VERSION}" ]]; then
121
+ fail "could not determine npm CLI version ('npm --version' produced no output)"
122
+ fi
123
+ if ! version_ge "${NPM_VERSION}" "${NPM_MIN_VERSION}"; then
124
+ fail "npm CLI version ${NPM_VERSION} is below the minimum required for staged publishing (${NPM_MIN_VERSION}). Upgrade with 'npm install -g npm@latest' (or any npm >= ${NPM_MIN_VERSION}) and re-run."
125
+ fi
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Check 2 — Node version >= 22.14.0
129
+ # ---------------------------------------------------------------------------
130
+ NODE_VERSION_RAW="$(node --version 2>/dev/null)"
131
+ NODE_VERSION="${NODE_VERSION_RAW#v}"
132
+ if [[ -z "${NODE_VERSION}" ]]; then
133
+ fail "could not determine Node.js version ('node --version' produced no output)"
134
+ fi
135
+ if ! version_ge "${NODE_VERSION}" "${NODE_MIN_VERSION}"; then
136
+ fail "Node.js version ${NODE_VERSION} is below the minimum required for staged publishing (${NODE_MIN_VERSION}). Upgrade Node.js to ${NODE_MIN_VERSION} or newer (https://nodejs.org/) and re-run."
137
+ fi
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Check 3 — target exists and its type is `npm` or `npm-ci`
141
+ # ---------------------------------------------------------------------------
142
+ TARGET_JSON="$(jq -c --arg t "${TARGET_NAME}" '.targets[]? | select(.name == $t)' "${CONFIG_PATH}")"
143
+ if [[ -z "${TARGET_JSON}" ]]; then
144
+ fail "target '${TARGET_NAME}' was not found in '${CONFIG_PATH}'"
145
+ fi
146
+
147
+ TARGET_TYPE="$(printf '%s' "${TARGET_JSON}" | jq -r '.type // empty')"
148
+ if [[ "${TARGET_TYPE}" != "npm" && "${TARGET_TYPE}" != "npm-ci" ]]; then
149
+ if [[ -z "${TARGET_TYPE}" ]]; then
150
+ fail "target '${TARGET_NAME}' is missing a type. Staging is only supported for target types 'npm' and 'npm-ci'."
151
+ fi
152
+ fail "target '${TARGET_NAME}' has type '${TARGET_TYPE}'. Staging is only supported for target types 'npm' and 'npm-ci' — use '/publish deploy' for other target types."
153
+ fi
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Check 4 — the package already exists on the registry
157
+ # ---------------------------------------------------------------------------
158
+ PACKAGE_NAME="$(printf '%s' "${TARGET_JSON}" | jq -r '.npm.package_name // empty')"
159
+ if [[ -z "${PACKAGE_NAME}" ]]; then
160
+ fail "target '${TARGET_NAME}' is missing required field 'npm.package_name'"
161
+ fi
162
+
163
+ REGISTRY="$(printf '%s' "${TARGET_JSON}" | jq -r --arg d "${DEFAULT_REGISTRY}" '.npm.registry // $d')"
164
+
165
+ # Capture npm's output but never print it — it is not needed for the
166
+ # messages below, and not printing it is a hard guarantee against ever
167
+ # leaking anything npm's registry client writes (including, in principle,
168
+ # auth-related response detail) into this script's own stdout/stderr.
169
+ REGISTRY_PROBE_OUTPUT="$(npm view "${PACKAGE_NAME}" version --registry "${REGISTRY}" 2>&1)"
170
+ REGISTRY_PROBE_EXIT=$?
171
+
172
+ if [[ ${REGISTRY_PROBE_EXIT} -ne 0 ]]; then
173
+ if printf '%s' "${REGISTRY_PROBE_OUTPUT}" | grep -qi 'E404\|404 Not Found\|is not in this registry'; then
174
+ fail "package '${PACKAGE_NAME}' was not found on registry '${REGISTRY}' (404). npm cannot stage a package that has never been published — run '/publish deploy' for the first release; staging becomes available from the second release onward."
175
+ fi
176
+ fail "could not verify that package '${PACKAGE_NAME}' exists on registry '${REGISTRY}' (npm view exited ${REGISTRY_PROBE_EXIT}). Check network connectivity and registry configuration, then re-run."
177
+ fi
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # All checks passed
181
+ # ---------------------------------------------------------------------------
182
+ printf '[validate] stage environment OK — npm %s, node %s, target "%s" (%s), package "%s" resolves on %s\n' \
183
+ "${NPM_VERSION}" "${NODE_VERSION}" "${TARGET_NAME}" "${TARGET_TYPE}" "${PACKAGE_NAME}" "${REGISTRY}"
184
+ exit 0