@jenga-ai/agent 1.0.1 → 1.1.1

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 (117) hide show
  1. package/README.md +10 -7
  2. package/agents/developer.md +82 -2
  3. package/agents/scrum-master.md +215 -21
  4. package/agents/tester.md +90 -8
  5. package/hooks/on_session_end.sh +171 -20
  6. package/mcp/router/embedder.js +1 -1
  7. package/mcp/training_runner/index.js +239 -0
  8. package/mcp/training_runner/package-lock.json +1065 -0
  9. package/mcp/training_runner/package.json +15 -0
  10. package/package.json +14 -16
  11. package/scripts/check-permission-level.sh +107 -0
  12. package/scripts/check-publicignore-match.sh +122 -0
  13. package/scripts/check-worktree-liveness.sh +193 -0
  14. package/scripts/generate-rapport-manifest.sh +43 -0
  15. package/scripts/idea_manager.sh +47 -0
  16. package/scripts/install-worktree-commit-guard.sh +134 -0
  17. package/scripts/jenga-permission-level-switch.sh +109 -0
  18. package/scripts/smoke-harness.sh +139 -0
  19. package/scripts/validate-board.sh +62 -0
  20. package/scripts/with-lock.sh +158 -0
  21. package/scripts/worktree-remove-guard.sh +204 -0
  22. package/skills/clearify/SKILL.md +52 -0
  23. package/skills/close-story/SKILL.md +203 -0
  24. package/skills/close-story/scripts/check-story-closeable.sh +195 -0
  25. package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
  26. package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
  27. package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
  28. package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
  29. package/skills/commit/SKILL.md +30 -3
  30. package/skills/distribute/CONFIG_SCHEMA.md +148 -0
  31. package/skills/distribute/SKILL.md +173 -0
  32. package/skills/distribute/scripts/check-version.sh +74 -0
  33. package/skills/distribute/scripts/commit-version-bump.sh +108 -0
  34. package/skills/distribute/scripts/distribute-changes.sh +381 -0
  35. package/skills/do/SKILL.md +352 -1
  36. package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
  37. package/skills/doc/assets/path-objectives.yaml +13 -0
  38. package/skills/doc-sync/SKILL.md +16 -0
  39. package/skills/doc-sync/assets/doc_targets.md +11 -0
  40. package/skills/idea/SKILL.md +56 -0
  41. package/skills/idea/assets/idea_handoff_template.md +26 -0
  42. package/skills/idea/assets/idea_template.md +3 -0
  43. package/skills/init/SKILL.md +101 -7
  44. package/skills/init/assets/directory_structure.txt +1 -0
  45. package/skills/init/assets/strategy_stub_template.md +38 -0
  46. package/skills/init/assets/workflow_template.json +1 -1
  47. package/skills/init/scripts/apply-project-visibility.sh +176 -0
  48. package/skills/init/scripts/detect-existing-codebase.sh +166 -0
  49. package/skills/init/scripts/init.sh +35 -1
  50. package/skills/jenga/SKILL.md +206 -14
  51. package/skills/jenga/scripts/board-scan.sh +238 -0
  52. package/skills/jenga/scripts/cascade-resolve.sh +297 -0
  53. package/skills/jenga/scripts/render-confirmation.sh +679 -0
  54. package/skills/jenga/scripts/render-picker.sh +439 -0
  55. package/skills/jenga/scripts/resolve-id.sh +367 -0
  56. package/skills/jenga-permission-level/SKILL.md +81 -0
  57. package/skills/proceed/SKILL.md +1 -1
  58. package/skills/publish/SKILL.md +8 -5
  59. package/skills/publish/assets/ci-contract.md +2 -2
  60. package/skills/publish/assets/ownership-matrix.md +1 -1
  61. package/skills/publish/scripts/finalize_changelog.sh +115 -0
  62. package/skills/publish/scripts/generate_release_notes.sh +475 -28
  63. package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
  64. package/skills/publish/scripts/publish_deploy.sh +38 -8
  65. package/skills/publish/scripts/run_gates.sh +2 -2
  66. package/skills/reconcile/SKILL.md +117 -5
  67. package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
  68. package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
  69. package/skills/spinoff/SKILL.md +12 -7
  70. package/skills/todo/SKILL.md +2 -0
  71. package/skills/uncharted/SKILL.md +711 -0
  72. package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
  73. package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
  74. package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
  75. package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
  76. package/skills/uncharted/scripts/detect-tests.sh +553 -0
  77. package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
  78. package/skills/uncharted/scripts/enumerate-target.sh +470 -0
  79. package/skills/uncharted/scripts/import-source.sh +517 -0
  80. package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
  81. package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
  82. package/skills/uncharted/scripts/run-engine.sh +655 -0
  83. package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
  84. package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
  85. package/skills/wtf/SKILL.md +20 -0
  86. package/templates/CHANGELOG_TEMPLATE.md +13 -0
  87. package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
  88. package/templates/SCRUM_BOARD_SCHEMA.md +206 -10
  89. package/templates/permission-levels/README.md +73 -0
  90. package/templates/permission-levels/level-1-locked.json +71 -0
  91. package/templates/permission-levels/level-2-guarded.json +64 -0
  92. package/templates/permission-levels/level-3-standard.json +62 -0
  93. package/templates/permission-levels/level-4-elevated.json +60 -0
  94. package/templates/permission-levels/level-5-unrestricted.json +58 -0
  95. package/skills/convert/SKILL.md +0 -124
  96. package/skills/convert/convert_cli.py +0 -235
  97. package/skills/convert/tests/sample.csv +0 -4
  98. package/skills/convert/tests/sample.json +0 -5
  99. package/skills/convert/tests/sample.jsonl +0 -3
  100. package/skills/convert/tests/sample.yaml +0 -18
  101. package/skills/convert/tests/sample_obj.csv +0 -2
  102. package/skills/convert/tests/sample_obj.json +0 -9
  103. package/skills/mirror-public/SKILL.md +0 -237
  104. package/skills/mirror-public/assets/config.json +0 -5
  105. package/skills/mirror-public/scripts/mirror.sh +0 -374
  106. package/skills/self-sync/SKILL.md +0 -73
  107. package/skills/self-sync/scripts/run.js +0 -136
  108. package/skills/train/SKILL.md +0 -116
  109. package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
  110. package/skills/train/assets/dashboard-templates/nlp.html +0 -102
  111. package/skills/train/assets/dashboard-templates/transformers.html +0 -98
  112. package/skills/train/assets/results-parsers/__init__.py +0 -9
  113. package/skills/train/assets/results-parsers/classifiers.py +0 -84
  114. package/skills/train/assets/results-parsers/nlp.py +0 -88
  115. package/skills/train/assets/results-parsers/reporter.py +0 -154
  116. package/skills/train/assets/results-parsers/transformers.py +0 -120
  117. package/skills/train/train_cli.py +0 -786
@@ -66,6 +66,83 @@ git rev-parse --verify "$TO_REF" >/dev/null 2>&1 || {
66
66
  exit 1
67
67
  }
68
68
 
69
+ # ---------------------------------------------------------------------------
70
+ # .publicignore pre-filter (E36_S02_T02).
71
+ #
72
+ # A commit (or completed task) whose changed files are ALL covered by a
73
+ # repo-root .publicignore blocklist must never surface in CHANGELOG.md,
74
+ # since CHANGELOG.md itself is not blocklisted and ships to the public
75
+ # mirror via /mirror-public. Partial overlap is not grounds for exclusion.
76
+ # Absence of .publicignore is a strict no-op — PI_ACTIVE stays 0 and every
77
+ # _pi_* helper below fails open immediately.
78
+ #
79
+ # Matching semantics are borrowed from skills/mirror-public/scripts/mirror.sh
80
+ # rather than reimplemented: that script's compute_ship_list asks rsync
81
+ # itself "what would transfer past --exclude-from=.publicignore?" and that
82
+ # is the single source of truth /mirror-public --dry-run reports as
83
+ # shipped/blocked. This script has no real destination tree to rsync into,
84
+ # so _pi_classify_paths below builds a throwaway stub tree with zero-byte
85
+ # files at each candidate path and asks rsync the identical question.
86
+ # ---------------------------------------------------------------------------
87
+
88
+ PUBLICIGNORE="$PUBLISH_REPO_ROOT/.publicignore"
89
+ PI_ACTIVE=0
90
+ PI_SHIPPED_FILE=""
91
+ trap '[[ -n "$PI_SHIPPED_FILE" ]] && rm -f "$PI_SHIPPED_FILE"' EXIT
92
+
93
+ if [[ -f "$PUBLICIGNORE" ]]; then
94
+ if command -v rsync >/dev/null 2>&1; then
95
+ PI_ACTIVE=1
96
+ else
97
+ echo "generate_release_notes.sh: rsync not found; skipping .publicignore filtering" >&2
98
+ fi
99
+ fi
100
+
101
+ # _pi_classify_paths <path-list-file> <out-shipped-file>
102
+ #
103
+ # <path-list-file> is a deduped, one-path-per-line file of repo-relative
104
+ # paths. Writes the subset rsync would transfer past .publicignore's
105
+ # --exclude-from (i.e. the NOT-blocked subset) to <out-shipped-file>. A path
106
+ # absent from that output is blocked.
107
+ _pi_classify_paths() {
108
+ local path_list="$1" out_file="$2"
109
+ local stub_src stub_dst p
110
+
111
+ stub_src="$(mktemp -d)"
112
+ stub_dst="$(mktemp -d)"
113
+
114
+ while IFS= read -r p || [[ -n "$p" ]]; do
115
+ [[ -n "$p" ]] || continue
116
+ mkdir -p "$stub_src/$(dirname "$p")"
117
+ : > "$stub_src/$p"
118
+ done < "$path_list"
119
+
120
+ rsync -a \
121
+ --exclude-from="$PUBLICIGNORE" \
122
+ --exclude=".git" \
123
+ --dry-run --itemize-changes --out-format='%i %n' \
124
+ "$stub_src/" "$stub_dst/" \
125
+ | awk '{
126
+ flag = $1;
127
+ first = substr(flag, 1, 1);
128
+ second = substr(flag, 2, 1);
129
+ if (second == "d") next;
130
+ keep = 0;
131
+ if (first == ">" || first == "<") keep = 1;
132
+ else if (first == "c" && second == "L") keep = 1;
133
+ else if (first == "h") keep = 1;
134
+ if (!keep) next;
135
+ $1 = "";
136
+ sub(/^ /, "");
137
+ sub(/\/$/, "");
138
+ if (length($0) > 0) print $0;
139
+ }' \
140
+ | LC_ALL=C sort -u > "$out_file"
141
+
142
+ rm -rf "$stub_src" "$stub_dst"
143
+ return 0
144
+ }
145
+
69
146
  HISTORY_FILE="$(publish_resolve_history_file "$CONFIG_PATH")"
70
147
  LAST_TAG="$FROM_TAG"
71
148
  FIRST_RELEASE=0
@@ -98,8 +175,83 @@ else
98
175
  LOG_RANGE=("$TO_REF")
99
176
  fi
100
177
 
101
- while IFS=$'\t' read -r subject short_sha || [[ -n "${subject:-}${short_sha:-}" ]]; do
178
+ COMMIT_SUBJECTS=()
179
+ COMMIT_SHORT_SHAS=()
180
+ COMMIT_FULL_SHAS=()
181
+
182
+ while IFS=$'\t' read -r subject short_sha full_sha || [[ -n "${subject:-}${short_sha:-}${full_sha:-}" ]]; do
102
183
  [[ -z "${subject:-}" ]] && continue
184
+ COMMIT_SUBJECTS+=("$subject")
185
+ COMMIT_SHORT_SHAS+=("$short_sha")
186
+ COMMIT_FULL_SHAS+=("$full_sha")
187
+ done < <(git log "${LOG_RANGE[@]}" --pretty=format:'%s%x09%h%x09%H')
188
+
189
+ # COMMIT_FILES[i] holds the newline-joined changed-file list for the commit
190
+ # at the same index as COMMIT_SUBJECTS/COMMIT_SHORT_SHAS/COMMIT_FULL_SHAS.
191
+ # Only populated when PI_ACTIVE, and only used to decide exclusion — it does
192
+ # not change classification (feat/fix/other) for anything that survives.
193
+ COMMIT_FILES=()
194
+
195
+ if (( PI_ACTIVE )) && (( ${#COMMIT_FULL_SHAS[@]} > 0 )); then
196
+ PI_ALL_PATHS_FILE="$(mktemp)"
197
+ : > "$PI_ALL_PATHS_FILE"
198
+ ci=0
199
+ while (( ci < ${#COMMIT_FULL_SHAS[@]} )); do
200
+ files="$(git diff-tree --no-commit-id --name-only -r "${COMMIT_FULL_SHAS[$ci]}" 2>/dev/null || true)"
201
+ COMMIT_FILES+=("$files")
202
+ if [[ -n "$files" ]]; then
203
+ printf '%s\n' "$files" >> "$PI_ALL_PATHS_FILE"
204
+ fi
205
+ ci=$((ci + 1))
206
+ done
207
+ LC_ALL=C sort -u "$PI_ALL_PATHS_FILE" -o "$PI_ALL_PATHS_FILE"
208
+ if [[ -s "$PI_ALL_PATHS_FILE" ]]; then
209
+ PI_SHIPPED_FILE="$(mktemp)"
210
+ _pi_classify_paths "$PI_ALL_PATHS_FILE" "$PI_SHIPPED_FILE"
211
+ fi
212
+ rm -f "$PI_ALL_PATHS_FILE"
213
+ fi
214
+
215
+ # _pi_commit_is_fully_blocked <index>
216
+ #
217
+ # True (return 0) only when the commit at COMMIT_FILES[<index>] changed at
218
+ # least one file and NONE of its changed files are in the shipped set (i.e.
219
+ # every changed file is blocked by .publicignore). A commit with zero
220
+ # changed files (e.g. an empty commit) has nothing to check and fails open.
221
+ _pi_commit_is_fully_blocked() {
222
+ local idx="$1" files f
223
+
224
+ if (( ! PI_ACTIVE )); then
225
+ return 1
226
+ fi
227
+ if [[ -z "$PI_SHIPPED_FILE" ]]; then
228
+ return 1
229
+ fi
230
+ files="${COMMIT_FILES[$idx]:-}"
231
+ if [[ -z "$files" ]]; then
232
+ return 1
233
+ fi
234
+
235
+ while IFS= read -r f || [[ -n "$f" ]]; do
236
+ [[ -n "$f" ]] || continue
237
+ if grep -qxF "$f" "$PI_SHIPPED_FILE" 2>/dev/null; then
238
+ return 1
239
+ fi
240
+ done <<< "$files"
241
+
242
+ return 0
243
+ }
244
+
245
+ ci=0
246
+ while (( ci < ${#COMMIT_SUBJECTS[@]} )); do
247
+ subject="${COMMIT_SUBJECTS[$ci]}"
248
+ short_sha="${COMMIT_SHORT_SHAS[$ci]}"
249
+
250
+ if _pi_commit_is_fully_blocked "$ci"; then
251
+ ci=$((ci + 1))
252
+ continue
253
+ fi
254
+
103
255
  line="- $subject ($short_sha)"
104
256
  if printf '%s\n' "$subject" | grep -Eq '^feat(\([^)]+\))?!?: '; then
105
257
  FEATURES+=("$line")
@@ -108,7 +260,53 @@ while IFS=$'\t' read -r subject short_sha || [[ -n "${subject:-}${short_sha:-}"
108
260
  else
109
261
  OTHER+=("$line")
110
262
  fi
111
- done < <(git log "${LOG_RANGE[@]}" --pretty=format:'%s%x09%h')
263
+ ci=$((ci + 1))
264
+ done
265
+
266
+ # _pi_task_is_fully_blocked <task_id>
267
+ #
268
+ # Best-effort task-to-commit association: finds commits in the resolved
269
+ # LOG_RANGE whose subject follows this repo's own EST commit convention
270
+ # (e.g. "task(E36_S02_T01): ..." — confirmed against real history), via a
271
+ # literal (non-regex) substring match on "($task_id)". If no associated
272
+ # commit is found, fails open (returns 1 — keep the entry): we only ever
273
+ # suppress an entry we have positive evidence for, never one we can't
274
+ # determine. If associated commits are found, unions their changed files
275
+ # and excludes the task only when every one of those files is blocked.
276
+ _pi_task_is_fully_blocked() {
277
+ local task_id="$1"
278
+ local shas all_paths tmp_ship sha result
279
+
280
+ result=1
281
+
282
+ if (( PI_ACTIVE )); then
283
+ shas="$(git log "${LOG_RANGE[@]}" --grep="($task_id)" -F --pretty=format:%H 2>/dev/null || true)"
284
+ if [[ -n "$shas" ]]; then
285
+ all_paths="$(mktemp)"
286
+ : > "$all_paths"
287
+ while IFS= read -r sha || [[ -n "$sha" ]]; do
288
+ [[ -n "$sha" ]] || continue
289
+ git diff-tree --no-commit-id --name-only -r "$sha" 2>/dev/null || true
290
+ done <<< "$shas" >> "$all_paths"
291
+ LC_ALL=C sort -u "$all_paths" -o "$all_paths"
292
+
293
+ if [[ -s "$all_paths" ]]; then
294
+ tmp_ship="$(mktemp)"
295
+ _pi_classify_paths "$all_paths" "$tmp_ship"
296
+ if [[ ! -s "$tmp_ship" ]]; then
297
+ result=0
298
+ fi
299
+ rm -f "$tmp_ship"
300
+ fi
301
+ rm -f "$all_paths"
302
+ fi
303
+ fi
304
+
305
+ if (( result == 0 )); then
306
+ return 0
307
+ fi
308
+ return 1
309
+ }
112
310
 
113
311
  collect_completed_tasks() {
114
312
  local cutoff_date="$1"
@@ -137,6 +335,11 @@ collect_completed_tasks() {
137
335
  task_title="$(awk '/^title: /{sub(/^title: /, ""); print; exit}' "$task_file" 2>/dev/null || true)"
138
336
  [[ -n "$task_id" ]] || continue
139
337
  [[ -n "$task_title" ]] || task_title="$task_id"
338
+
339
+ if (( PI_ACTIVE )) && _pi_task_is_fully_blocked "$task_id"; then
340
+ continue
341
+ fi
342
+
140
343
  collected+=("- $task_id — $task_title")
141
344
  done < <(find "$tasks_dir" -maxdepth 1 -type f -name '*.md' -print0 2>/dev/null)
142
345
 
@@ -145,8 +348,11 @@ collect_completed_tasks() {
145
348
  fi
146
349
  }
147
350
 
148
- if [[ -z "$OUTPUT_PATH" ]]; then
149
- OUTPUT_PATH="$(publish_temp_release_notes_path)"
351
+ STANDALONE=0
352
+ if [[ -n "$OUTPUT_PATH" ]]; then
353
+ STANDALONE=1
354
+ else
355
+ OUTPUT_PATH="$PUBLISH_REPO_ROOT/CHANGELOG.md"
150
356
  fi
151
357
  mkdir -p "$(dirname "$OUTPUT_PATH")"
152
358
 
@@ -167,34 +373,275 @@ write_section() {
167
373
  } >> "$OUTPUT_PATH"
168
374
  }
169
375
 
170
- GENERATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
171
- {
172
- printf '# Release notes\n\n'
173
- printf -- '- Generated: %s\n' "$GENERATED_AT"
174
- if [[ -n "$TARGET_NAME" ]]; then
175
- printf -- '- Target: %s\n' "$TARGET_NAME"
176
- fi
177
- if [[ -n "$LAST_TAG" ]]; then
178
- printf -- '- Changes: %s..%s\n\n' "$LAST_TAG" "$TO_REF"
376
+ # ---------------------------------------------------------------------------
377
+ # Standing-changelog merge helpers.
378
+ #
379
+ # These operate on a plain indexed bash array (CL_LINES), loaded via a
380
+ # `while read` loop rather than `mapfile`/`readarray`. This repo's target
381
+ # shell is bash 3.2 (the stock macOS default; no Homebrew bash is assumed on
382
+ # PATH), which lacks `mapfile`, `declare -A`, and `local -n` namerefs
383
+ # (bash 4.0/4.3+ only) — none of those are used anywhere else in this repo's
384
+ # scripts either. Helper functions rely on bash's dynamic scoping (a
385
+ # function's `local`s are visible to functions it calls) instead of
386
+ # namerefs to share the working array and computed indices.
387
+ # ---------------------------------------------------------------------------
388
+
389
+ CL_LINES=()
390
+ CL_UNRELEASED_START=-1
391
+ CL_UNRELEASED_END=-1
392
+ CL_SUB_HEAD=-1
393
+ CL_SUB_END=-1
394
+ CL_NEW_ITEMS=()
395
+ CL_FILTERED=()
396
+
397
+ _cl_load() {
398
+ CL_LINES=()
399
+ local line
400
+ while IFS= read -r line || [[ -n "$line" ]]; do
401
+ CL_LINES+=("$line")
402
+ done < "$1"
403
+ }
404
+
405
+ _cl_save() {
406
+ if (( ${#CL_LINES[@]} > 0 )); then
407
+ printf '%s\n' "${CL_LINES[@]}" > "$1"
179
408
  else
180
- printf -- '- Changes: full history through %s\n\n' "$TO_REF"
181
- if (( FIRST_RELEASE )); then
182
- printf '> First release — full history included\n\n'
409
+ : > "$1"
410
+ fi
411
+ }
412
+
413
+ # Locates the `## [Unreleased]` heading and the boundary of its section (the
414
+ # next line starting with `## `, or EOF). Sets CL_UNRELEASED_START/_END.
415
+ _cl_locate_unreleased() {
416
+ local n=${#CL_LINES[@]}
417
+ local i
418
+ CL_UNRELEASED_START=-1
419
+ CL_UNRELEASED_END=$n
420
+ for (( i=0; i<n; i++ )); do
421
+ if [[ "${CL_LINES[$i]}" == "## [Unreleased]" ]]; then
422
+ CL_UNRELEASED_START=$i
423
+ break
183
424
  fi
425
+ done
426
+ (( CL_UNRELEASED_START >= 0 )) || return 1
427
+ for (( i=CL_UNRELEASED_START+1; i<n; i++ )); do
428
+ case "${CL_LINES[$i]}" in
429
+ "## "*) CL_UNRELEASED_END=$i; return 0 ;;
430
+ esac
431
+ done
432
+ return 0
433
+ }
434
+
435
+ # Locates a `### <heading>` subsection within the current Unreleased bounds
436
+ # and its own boundary (next `### ` line, or the Unreleased boundary). Sets
437
+ # CL_SUB_HEAD (-1 if the heading doesn't exist yet) and CL_SUB_END.
438
+ _cl_locate_subsection() {
439
+ local heading="$1"
440
+ local j k
441
+ CL_SUB_HEAD=-1
442
+ CL_SUB_END=$CL_UNRELEASED_END
443
+ for (( j=CL_UNRELEASED_START+1; j<CL_UNRELEASED_END; j++ )); do
444
+ if [[ "${CL_LINES[$j]}" == "### $heading" ]]; then
445
+ CL_SUB_HEAD=$j
446
+ CL_SUB_END=$CL_UNRELEASED_END
447
+ for (( k=j+1; k<CL_UNRELEASED_END; k++ )); do
448
+ case "${CL_LINES[$k]}" in
449
+ "### "*) CL_SUB_END=$k; break ;;
450
+ esac
451
+ done
452
+ break
453
+ fi
454
+ done
455
+ return 0
456
+ }
457
+
458
+ # Filters CL_NEW_ITEMS down to entries not already present within the
459
+ # current CL_SUB_HEAD..CL_SUB_END bounds, into CL_FILTERED. Dedup key is the
460
+ # trailing "(shortsha)" token for mode=suffix (git-log-derived entries), or
461
+ # the "- $task_id —" prefix for mode=prefix (completed-task entries).
462
+ _cl_filter_new() {
463
+ local mode="$1"
464
+ CL_FILTERED=()
465
+ # bash 3.2 (no Homebrew bash assumed on PATH) treats a zero-element array
466
+ # expansion as unset under `set -u`, so every "${arr[@]}" below is guarded
467
+ # by an element-count check first — same convention collect_completed_tasks()
468
+ # already uses in this file.
469
+ (( ${#CL_NEW_ITEMS[@]} > 0 )) || return 0
470
+ if (( CL_SUB_HEAD < 0 )); then
471
+ CL_FILTERED=("${CL_NEW_ITEMS[@]}")
472
+ return 0
184
473
  fi
185
- } > "$OUTPUT_PATH"
474
+ local item key dup l
475
+ for item in "${CL_NEW_ITEMS[@]}"; do
476
+ if [[ "$mode" == "suffix" ]]; then
477
+ key="${item##* }"
478
+ else
479
+ key="${item%% — *} —"
480
+ fi
481
+ dup=0
482
+ for (( l=CL_SUB_HEAD+1; l<CL_SUB_END; l++ )); do
483
+ if [[ "${CL_LINES[$l]}" == *"$key"* ]]; then
484
+ dup=1
485
+ break
486
+ fi
487
+ done
488
+ (( dup )) || CL_FILTERED+=("$item")
489
+ done
490
+ return 0
491
+ }
186
492
 
187
- write_section 'Features' FEATURES
188
- write_section 'Bug Fixes' BUG_FIXES
189
- write_section 'Other' OTHER
493
+ # Splices the given lines into CL_LINES at index $1.
494
+ #
495
+ # NOTE: guards below use explicit `if` rather than `(( cond )) && action`.
496
+ # The latter is fine as a mid-function statement, but bash's `set -e`
497
+ # propagates a false `(( cond ))` left operand as the *function's own*
498
+ # return status when it is the last statement executed in the function —
499
+ # even though the same construct is a documented-safe no-op at the top
500
+ # level of a script. That silently aborted the whole script under
501
+ # `set -euo pipefail` the first time this was exercised end-to-end.
502
+ _cl_splice() {
503
+ local at="$1"; shift
504
+ local before=()
505
+ local after=()
506
+ if (( at > 0 )); then
507
+ before=("${CL_LINES[@]:0:at}")
508
+ fi
509
+ if (( at < ${#CL_LINES[@]} )); then
510
+ after=("${CL_LINES[@]:at}")
511
+ fi
512
+ CL_LINES=()
513
+ if (( ${#before[@]} > 0 )); then
514
+ CL_LINES+=("${before[@]}")
515
+ fi
516
+ if (( $# > 0 )); then
517
+ CL_LINES+=("$@")
518
+ fi
519
+ if (( ${#after[@]} > 0 )); then
520
+ CL_LINES+=("${after[@]}")
521
+ fi
522
+ return 0
523
+ }
524
+
525
+ # Dedups CL_NEW_ITEMS against the named `### <heading>` subsection (creating
526
+ # the heading at the end of the Unreleased section if it doesn't exist yet —
527
+ # used for "Completed tasks", which the template doesn't pre-declare) and
528
+ # splices in whatever survives. Re-locates the Unreleased/subsection bounds
529
+ # fresh on every call, so calls can run in the subsections' natural file
530
+ # order (Features, Bug Fixes, Other, Completed tasks) without reasoning
531
+ # about earlier splices invalidating later indices.
532
+ _cl_process_subsection() {
533
+ local heading="$1" mode="$2"
534
+
535
+ _cl_locate_unreleased
536
+ _cl_locate_subsection "$heading"
537
+ _cl_filter_new "$mode"
538
+
539
+ (( ${#CL_FILTERED[@]} > 0 )) || return 0
540
+
541
+ if (( CL_SUB_HEAD >= 0 )); then
542
+ local anchor=$CL_SUB_HEAD
543
+ local m
544
+ for (( m=CL_SUB_HEAD+1; m<CL_SUB_END; m++ )); do
545
+ if [[ -n "${CL_LINES[$m]}" ]]; then
546
+ anchor=$m
547
+ fi
548
+ done
549
+ _cl_splice "$((anchor + 1))" "${CL_FILTERED[@]}"
550
+ else
551
+ local insert_at=$CL_UNRELEASED_END
552
+ local new_block=()
553
+ if (( insert_at > CL_UNRELEASED_START + 1 )) && [[ -n "${CL_LINES[$((insert_at - 1))]}" ]]; then
554
+ new_block+=("")
555
+ fi
556
+ new_block+=("### $heading" "" "${CL_FILTERED[@]}")
557
+ if (( insert_at < ${#CL_LINES[@]} )); then
558
+ new_block+=("")
559
+ fi
560
+ _cl_splice "$insert_at" "${new_block[@]}"
561
+ fi
562
+ return 0
563
+ }
190
564
 
191
- COMPLETED_TASKS="$(collect_completed_tasks "$CUTOFF_DATE" || true)"
192
- if [[ -n "$COMPLETED_TASKS" ]]; then
565
+ if (( STANDALONE )); then
566
+ GENERATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
193
567
  {
194
- printf '### Completed tasks\n'
195
- printf '%s\n' "$COMPLETED_TASKS"
196
- printf '\n'
197
- } >> "$OUTPUT_PATH"
198
- fi
568
+ printf '# Release notes\n\n'
569
+ printf -- '- Generated: %s\n' "$GENERATED_AT"
570
+ if [[ -n "$TARGET_NAME" ]]; then
571
+ printf -- '- Target: %s\n' "$TARGET_NAME"
572
+ fi
573
+ if [[ -n "$LAST_TAG" ]]; then
574
+ printf -- '- Changes: %s..%s\n\n' "$LAST_TAG" "$TO_REF"
575
+ else
576
+ printf -- '- Changes: full history through %s\n\n' "$TO_REF"
577
+ if (( FIRST_RELEASE )); then
578
+ printf '> First release — full history included\n\n'
579
+ fi
580
+ fi
581
+ } > "$OUTPUT_PATH"
582
+
583
+ write_section 'Features' FEATURES
584
+ write_section 'Bug Fixes' BUG_FIXES
585
+ write_section 'Other' OTHER
586
+
587
+ COMPLETED_TASKS="$(collect_completed_tasks "$CUTOFF_DATE" || true)"
588
+ if [[ -n "$COMPLETED_TASKS" ]]; then
589
+ {
590
+ printf '### Completed tasks\n'
591
+ printf '%s\n' "$COMPLETED_TASKS"
592
+ printf '\n'
593
+ } >> "$OUTPUT_PATH"
594
+ fi
595
+
596
+ printf '📝 Release notes draft written to %s\n' "$OUTPUT_PATH"
597
+ else
598
+ CHANGELOG_TEMPLATE="$PUBLISH_REPO_ROOT/templates/CHANGELOG_TEMPLATE.md"
599
+ if [[ ! -f "$OUTPUT_PATH" ]]; then
600
+ [[ -f "$CHANGELOG_TEMPLATE" ]] || {
601
+ echo "Changelog template not found: $CHANGELOG_TEMPLATE" >&2
602
+ exit 1
603
+ }
604
+ cp "$CHANGELOG_TEMPLATE" "$OUTPUT_PATH"
605
+ fi
199
606
 
200
- printf '📝 Release notes draft written to %s\n' "$OUTPUT_PATH"
607
+ _cl_load "$OUTPUT_PATH"
608
+ _cl_locate_unreleased || {
609
+ echo "generate_release_notes.sh: '## [Unreleased]' section not found in $OUTPUT_PATH — cannot merge entries." >&2
610
+ exit 1
611
+ }
612
+
613
+ CL_NEW_ITEMS=()
614
+ if (( ${#FEATURES[@]} > 0 )); then
615
+ CL_NEW_ITEMS=("${FEATURES[@]}")
616
+ fi
617
+ _cl_process_subsection 'Features' suffix
618
+
619
+ CL_NEW_ITEMS=()
620
+ if (( ${#BUG_FIXES[@]} > 0 )); then
621
+ CL_NEW_ITEMS=("${BUG_FIXES[@]}")
622
+ fi
623
+ _cl_process_subsection 'Bug Fixes' suffix
624
+
625
+ CL_NEW_ITEMS=()
626
+ if (( ${#OTHER[@]} > 0 )); then
627
+ CL_NEW_ITEMS=("${OTHER[@]}")
628
+ fi
629
+ _cl_process_subsection 'Other' suffix
630
+
631
+ COMPLETED_TASKS="$(collect_completed_tasks "$CUTOFF_DATE" || true)"
632
+ CL_NEW_ITEMS=()
633
+ if [[ -n "$COMPLETED_TASKS" ]]; then
634
+ OLD_IFS="$IFS"
635
+ IFS=$'\n'
636
+ # Intentional word-split on newline (no mapfile in bash 3.2); each
637
+ # collect_completed_tasks() line is a single "- ID — title" entry.
638
+ # shellcheck disable=SC2206
639
+ CL_NEW_ITEMS=($COMPLETED_TASKS)
640
+ IFS="$OLD_IFS"
641
+ fi
642
+ _cl_process_subsection 'Completed tasks' prefix
643
+
644
+ _cl_save "$OUTPUT_PATH"
645
+
646
+ printf '📝 CHANGELOG.md updated: %s\n' "$OUTPUT_PATH"
647
+ fi
@@ -38,10 +38,11 @@ source "${SCRIPT_DIR}/publish_common.sh"
38
38
  TARGET_NAME=""
39
39
  CONFIG_PATH=""
40
40
  DRY_RUN=0
41
+ VERSION=""
41
42
 
42
43
  usage() {
43
44
  cat <<'USAGE'
44
- Usage: npm_ci_pipeline.sh --target <name> --config <path> [--dry-run]
45
+ Usage: npm_ci_pipeline.sh --target <name> --config <path> [--version <vX.Y.Z>] [--dry-run]
45
46
  USAGE
46
47
  }
47
48
 
@@ -75,6 +76,18 @@ while [[ $# -gt 0 ]]; do
75
76
  DRY_RUN=1
76
77
  shift
77
78
  ;;
79
+ --version)
80
+ [[ $# -ge 2 ]] || fail_usage "--version requires a value"
81
+ VERSION="$2"
82
+ shift 2
83
+ ;;
84
+ --version=*)
85
+ VERSION="${1#*=}"
86
+ shift
87
+ ;;
88
+ --non-interactive)
89
+ shift
90
+ ;;
78
91
  -h|--help)
79
92
  usage
80
93
  exit 0
@@ -182,6 +195,17 @@ if (( DRY_RUN )); then
182
195
  exit 0
183
196
  fi
184
197
 
198
+ # ---------------------------------------------------------------------------
199
+ # Phase: bump-version
200
+ # ---------------------------------------------------------------------------
201
+ if [[ -n "${VERSION}" ]]; then
202
+ # Strip leading 'v' — npm version expects bare semver (e.g. 1.0.1, not v1.0.1)
203
+ NPM_VERSION="${VERSION#v}"
204
+ printf '[bump-version] setting package.json version to %s...\n' "${NPM_VERSION}"
205
+ npm --prefix "${REPO_ROOT}" version "${NPM_VERSION}" --no-git-tag-version --allow-same-version
206
+ printf '[bump-version] done\n'
207
+ fi
208
+
185
209
  # ---------------------------------------------------------------------------
186
210
  # Phase: commit-workflow
187
211
  # ---------------------------------------------------------------------------
@@ -190,16 +214,30 @@ printf '[commit-workflow] writing workflow file...\n'
190
214
  mkdir -p "${WORKFLOW_DIR}"
191
215
  printf '%s\n' "${WORKFLOW_YAML}" > "${WORKFLOW_FILE}"
192
216
 
193
- if git -C "${REPO_ROOT}" diff --quiet -- "${WORKFLOW_FILE_REL}" 2>/dev/null && \
194
- git -C "${REPO_ROOT}" diff --cached --quiet -- "${WORKFLOW_FILE_REL}" 2>/dev/null && \
195
- git -C "${REPO_ROOT}" ls-files --error-unmatch "${WORKFLOW_FILE_REL}" >/dev/null 2>&1; then
217
+ CHANGED=0
218
+ git -C "${REPO_ROOT}" diff --quiet -- "${WORKFLOW_FILE_REL}" 2>/dev/null || CHANGED=1
219
+ git -C "${REPO_ROOT}" diff --quiet -- package.json package-lock.json 2>/dev/null || CHANGED=1
220
+ if git -C "${REPO_ROOT}" ls-files --error-unmatch "${WORKFLOW_FILE_REL}" >/dev/null 2>&1 && [[ $CHANGED -eq 0 ]]; then
196
221
  printf '[commit-workflow] no changes — skipping commit\n'
197
222
  else
198
- git -C "${REPO_ROOT}" add "${WORKFLOW_FILE_REL}"
199
- git -C "${REPO_ROOT}" commit -m "chore(publish): update npm CI workflow for target ${TARGET_NAME}"
223
+ git -C "${REPO_ROOT}" add "${WORKFLOW_FILE_REL}" package.json
224
+ [[ -f "${REPO_ROOT}/package-lock.json" ]] && git -C "${REPO_ROOT}" add package-lock.json
225
+ git -C "${REPO_ROOT}" commit -m "chore(publish): bump to ${VERSION:-unknown} and update npm CI workflow"
200
226
  printf '[commit-workflow] committed: %s\n' "${WORKFLOW_FILE}"
201
227
  fi
202
228
 
229
+ # ---------------------------------------------------------------------------
230
+ # Phase: push-workflow
231
+ # ---------------------------------------------------------------------------
232
+ printf '[push-workflow] pushing workflow commit to origin...\n'
233
+
234
+ if ! git -C "${REPO_ROOT}" push origin HEAD; then
235
+ printf '[push-workflow] ERROR: git push failed\n' >&2
236
+ exit "${EXIT_TRIGGER_FAILED}"
237
+ fi
238
+
239
+ printf '[push-workflow] pushed successfully\n'
240
+
203
241
  # ---------------------------------------------------------------------------
204
242
  # Phase: trigger
205
243
  # ---------------------------------------------------------------------------