@jenga-ai/agent 1.1.0 → 1.2.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.
- package/README.md +7 -3
- package/agents/developer.md +82 -2
- package/agents/scrum-master.md +140 -21
- package/agents/tester.md +90 -8
- package/hooks/on_session_end.sh +171 -20
- package/package.json +1 -1
- package/scripts/check-permission-level.sh +107 -0
- package/scripts/check-publicignore-match.sh +122 -0
- package/scripts/check-worktree-liveness.sh +193 -0
- package/scripts/generate-rapport-manifest.sh +43 -0
- package/scripts/idea_manager.sh +47 -0
- package/scripts/install-worktree-commit-guard.sh +134 -0
- package/scripts/jenga-permission-level-switch.sh +109 -0
- package/scripts/smoke-harness.sh +139 -0
- package/scripts/validate-board.sh +62 -0
- package/scripts/with-lock.sh +158 -0
- package/scripts/worktree-remove-guard.sh +204 -0
- package/skills/clearify/SKILL.md +52 -0
- package/skills/commit/SKILL.md +13 -4
- package/skills/distribute/CONFIG_SCHEMA.md +60 -2
- package/skills/do/SKILL.md +48 -11
- package/skills/doc-sync/SKILL.md +16 -0
- package/skills/doc-sync/assets/doc_targets.md +11 -0
- package/skills/idea/SKILL.md +56 -0
- package/skills/idea/assets/idea_handoff_template.md +26 -0
- package/skills/idea/assets/idea_template.md +3 -0
- package/skills/init/SKILL.md +100 -7
- package/skills/init/assets/directory_structure.txt +1 -0
- package/skills/init/assets/workflow_template.json +1 -1
- package/skills/init/scripts/apply-project-visibility.sh +176 -0
- package/skills/init/scripts/detect-existing-codebase.sh +166 -0
- package/skills/init/scripts/init.sh +30 -1
- package/skills/jenga/SKILL.md +160 -17
- package/skills/jenga/scripts/board-scan.sh +238 -0
- package/skills/jenga/scripts/cascade-resolve.sh +297 -0
- package/skills/jenga/scripts/render-confirmation.sh +679 -0
- package/skills/jenga/scripts/render-picker.sh +439 -0
- package/skills/jenga/scripts/resolve-id.sh +367 -0
- package/skills/jenga-permission-level/SKILL.md +81 -0
- package/skills/proceed/SKILL.md +1 -1
- package/skills/publish/SKILL.md +8 -5
- package/skills/publish/assets/ci-contract.md +2 -2
- package/skills/publish/assets/ownership-matrix.md +1 -1
- package/skills/publish/scripts/finalize_changelog.sh +115 -0
- package/skills/publish/scripts/generate_release_notes.sh +475 -28
- package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
- package/skills/publish/scripts/publish_deploy.sh +38 -8
- package/skills/publish/scripts/run_gates.sh +2 -2
- package/skills/reconcile/SKILL.md +117 -5
- package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
- package/skills/spinoff/SKILL.md +12 -7
- package/skills/todo/SKILL.md +2 -0
- package/skills/uncharted/SKILL.md +711 -0
- package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
- package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
- package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
- package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
- package/skills/uncharted/scripts/detect-tests.sh +553 -0
- package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
- package/skills/uncharted/scripts/enumerate-target.sh +470 -0
- package/skills/uncharted/scripts/import-source.sh +517 -0
- package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
- package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
- package/skills/uncharted/scripts/run-engine.sh +655 -0
- package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
- package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
- package/skills/wtf/SKILL.md +20 -0
- package/templates/CHANGELOG_TEMPLATE.md +13 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +157 -10
- package/templates/permission-levels/README.md +73 -0
- package/templates/permission-levels/level-1-locked.json +71 -0
- package/templates/permission-levels/level-2-guarded.json +64 -0
- package/templates/permission-levels/level-3-standard.json +62 -0
- package/templates/permission-levels/level-4-elevated.json +60 -0
- package/templates/permission-levels/level-5-unrestricted.json +58 -0
- package/skills/convert/SKILL.md +0 -124
- package/skills/convert/convert_cli.py +0 -235
- package/skills/convert/tests/sample.csv +0 -4
- package/skills/convert/tests/sample.json +0 -5
- package/skills/convert/tests/sample.jsonl +0 -3
- package/skills/convert/tests/sample.yaml +0 -18
- package/skills/convert/tests/sample_obj.csv +0 -2
- package/skills/convert/tests/sample_obj.json +0 -9
- package/skills/mirror-public/SKILL.md +0 -237
- package/skills/mirror-public/assets/config.json +0 -5
- package/skills/mirror-public/scripts/mirror.sh +0 -374
- package/skills/self-sync/SKILL.md +0 -73
- package/skills/self-sync/scripts/run.js +0 -136
- package/skills/strategy/SKILL.md +0 -312
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# skills/jenga/scripts/resolve-id.sh
|
|
4
|
+
#
|
|
5
|
+
# Deterministic fuzzy-ID grammar parser and board-backed resolver for
|
|
6
|
+
# `/jenga`'s interactive scope-selection flow (E45). Given the raw,
|
|
7
|
+
# comma-separated ID string a user typed for `/jenga <ids>` (or IDs
|
|
8
|
+
# translated back from picker selection numbers), this script parses each
|
|
9
|
+
# comma-delimited segment per the exact grammar negotiated with the user,
|
|
10
|
+
# then resolves the parsed candidate against the real board inventory
|
|
11
|
+
# produced by `skills/jenga/scripts/board-scan.sh` (E45_S01_T01).
|
|
12
|
+
#
|
|
13
|
+
# This script NEVER re-scans the board independently — it invokes
|
|
14
|
+
# board-scan.sh as a subprocess and reads its JSON stdout as the single
|
|
15
|
+
# source of truth for what IDs actually exist. See board-scan.sh's own
|
|
16
|
+
# header comment for its output schema.
|
|
17
|
+
#
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# GRAMMAR (final — negotiated with the user, do not reinterpret)
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# 1. Strip whitespace, underscores, and punctuation from the input;
|
|
22
|
+
# uppercase it. Only [A-Z0-9] characters survive this step.
|
|
23
|
+
# 2. Split each contiguous digit run into 2-digit chunks, left to right.
|
|
24
|
+
# A digit run whose length is not a multiple of 2 is REJECTED as
|
|
25
|
+
# invalid input (never silently truncated or padded).
|
|
26
|
+
# 3. A chunk is TAGGED to a level (epic/story/task) when the digit run it
|
|
27
|
+
# came from is immediately adjacent — directly before or directly
|
|
28
|
+
# after, no separator survives step 1 — to a single E/S/T letter.
|
|
29
|
+
# 4. Untagged chunks fill the remaining levels positionally, in
|
|
30
|
+
# epic -> story -> task order, left to right, skipping any level
|
|
31
|
+
# already claimed by an explicit tag.
|
|
32
|
+
# 5. The parsed candidate (full or partial) is resolved against the board
|
|
33
|
+
# inventory. A partial ID that does not uniquely resolve (e.g. a story
|
|
34
|
+
# chunk with no epic tag, when multiple epics share that story number)
|
|
35
|
+
# is REJECTED and reports that disambiguation is required — never
|
|
36
|
+
# guessed across epics.
|
|
37
|
+
#
|
|
38
|
+
# Negotiated examples (must resolve exactly as documented):
|
|
39
|
+
# "E01s02" -> E01_S02
|
|
40
|
+
# "e01 S04 t02" -> E01_S04_T02
|
|
41
|
+
# "0103" -> E01_S03 (two untagged chunks: epic=01, story=03)
|
|
42
|
+
#
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# USAGE
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# skills/jenga/scripts/resolve-id.sh "<comma-separated raw ID list>"
|
|
47
|
+
#
|
|
48
|
+
# Example:
|
|
49
|
+
# skills/jenga/scripts/resolve-id.sh "E01s02, e01 S04 t02, 0103"
|
|
50
|
+
#
|
|
51
|
+
# Each comma-delimited segment of the argument is parsed and resolved
|
|
52
|
+
# independently. Whitespace around commas is insignificant (stripped by the
|
|
53
|
+
# grammar itself in step 1, since spaces are whitespace).
|
|
54
|
+
#
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# OUTPUT SCHEMA
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# stdout is a single JSON array, one object per input segment, in input
|
|
59
|
+
# order. Nothing else is ever written to stdout.
|
|
60
|
+
#
|
|
61
|
+
# {
|
|
62
|
+
# "input": "E01s02", // the raw segment exactly as given
|
|
63
|
+
# "status": "resolved" | "rejected",
|
|
64
|
+
# "resolved_id": "E01_S02" | null,
|
|
65
|
+
# "reason": null | "<human-readable rejection reason>"
|
|
66
|
+
# }
|
|
67
|
+
#
|
|
68
|
+
# Rejection reasons include (not exhaustive, always human-readable):
|
|
69
|
+
# - "empty input after stripping whitespace/underscores/punctuation"
|
|
70
|
+
# - "malformed digit run: '<run>' has odd length <n> (not a multiple of 2)"
|
|
71
|
+
# - "too many untagged chunks: <n> chunks but only <m> open level(s)"
|
|
72
|
+
# - "no ID chunks found in input"
|
|
73
|
+
# - "conflicting tags: level '<L>' tagged more than once"
|
|
74
|
+
# - "not found on board: <id-or-partial-description>"
|
|
75
|
+
# - "ambiguous partial ID '<partial>': matches <n> candidates
|
|
76
|
+
# (<id1>, <id2>, ...) - disambiguation required"
|
|
77
|
+
#
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# EXIT CODES
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# 0 every segment resolved
|
|
82
|
+
# 1 at least one segment was rejected (stdout is still valid JSON;
|
|
83
|
+
# callers should inspect each element's "status", not rely on the
|
|
84
|
+
# exit code alone to know WHICH segment failed)
|
|
85
|
+
# 2 usage error (no argument given) or board-scan.sh / python3 failure
|
|
86
|
+
# — a real setup problem, not a per-segment parse failure
|
|
87
|
+
#
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
set -euo pipefail
|
|
91
|
+
|
|
92
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
93
|
+
|
|
94
|
+
# Unlike board-scan.sh, this script never reads project/board/ directly —
|
|
95
|
+
# it only needs SCRIPT_DIR to locate and invoke board-scan.sh, which does
|
|
96
|
+
# its own JENGA_PROJECT_DIR resolution internally. No project-root
|
|
97
|
+
# resolution is needed here.
|
|
98
|
+
BOARD_SCAN="$SCRIPT_DIR/board-scan.sh"
|
|
99
|
+
|
|
100
|
+
if [ $# -lt 1 ] || [ -z "${1:-}" ]; then
|
|
101
|
+
echo 'Usage: resolve-id.sh "<comma-separated raw ID list>"' >&2
|
|
102
|
+
exit 2
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
RAW_INPUT="$1"
|
|
106
|
+
|
|
107
|
+
if [ ! -x "$BOARD_SCAN" ]; then
|
|
108
|
+
echo "Error: board-scan.sh not found or not executable at $BOARD_SCAN" >&2
|
|
109
|
+
exit 2
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
113
|
+
echo "Error: python3 is required by resolve-id.sh" >&2
|
|
114
|
+
exit 2
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# Capture the board inventory ONCE for this whole run — every segment is
|
|
118
|
+
# resolved against the same snapshot, and we never re-scan per segment.
|
|
119
|
+
BOARD_JSON="$("$BOARD_SCAN")" || {
|
|
120
|
+
echo "Error: board-scan.sh failed" >&2
|
|
121
|
+
exit 2
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# The parsing/resolution logic is written to a temp .py file rather than
|
|
125
|
+
# piped in via `python3 -` because the board JSON is delivered on stdin —
|
|
126
|
+
# `python3 -` would consume stdin itself as the script source, leaving
|
|
127
|
+
# nothing for the script's own sys.stdin.read() to see.
|
|
128
|
+
PY_SCRIPT="$(mktemp -t resolve-id-XXXXXX.py)"
|
|
129
|
+
trap 'rm -f "$PY_SCRIPT"' EXIT
|
|
130
|
+
|
|
131
|
+
cat > "$PY_SCRIPT" <<'PY'
|
|
132
|
+
import json
|
|
133
|
+
import re
|
|
134
|
+
import sys
|
|
135
|
+
|
|
136
|
+
raw_input = sys.argv[1]
|
|
137
|
+
board_json = sys.stdin.read()
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
board = json.loads(board_json)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
print(f"Error: could not parse board-scan.sh output as JSON: {e}", file=sys.stderr)
|
|
143
|
+
sys.exit(2)
|
|
144
|
+
|
|
145
|
+
LEVELS = ("epic", "story", "task")
|
|
146
|
+
LEVEL_LETTER = {"epic": "E", "story": "S", "task": "T"}
|
|
147
|
+
LETTER_LEVEL = {"E": "epic", "S": "story", "T": "task"}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def strip_and_upper(segment):
|
|
151
|
+
"""Grammar step 1: strip whitespace/underscores/punctuation, uppercase.
|
|
152
|
+
Only [A-Za-z0-9] survives."""
|
|
153
|
+
return re.sub(r"[^A-Za-z0-9]", "", segment).upper()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def tokenize(cleaned):
|
|
157
|
+
"""Split the cleaned string into an ordered list of
|
|
158
|
+
(kind, value) tokens where kind is 'digits' or 'letters', preserving
|
|
159
|
+
adjacency (walks left to right, alternating runs)."""
|
|
160
|
+
tokens = []
|
|
161
|
+
for m in re.finditer(r"[0-9]+|[A-Z]+", cleaned):
|
|
162
|
+
val = m.group(0)
|
|
163
|
+
kind = "digits" if val.isdigit() else "letters"
|
|
164
|
+
tokens.append((kind, val))
|
|
165
|
+
return tokens
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RejectError(Exception):
|
|
169
|
+
def __init__(self, reason):
|
|
170
|
+
self.reason = reason
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_segment(cleaned):
|
|
174
|
+
"""Returns a dict {level: '01'} chunk map (2-digit strings) covering
|
|
175
|
+
only the levels present in this candidate. Raises RejectError on any
|
|
176
|
+
grammar violation."""
|
|
177
|
+
if not cleaned:
|
|
178
|
+
raise RejectError("empty input after stripping whitespace/underscores/punctuation")
|
|
179
|
+
|
|
180
|
+
tokens = tokenize(cleaned)
|
|
181
|
+
|
|
182
|
+
digit_positions = [i for i, (kind, _) in enumerate(tokens) if kind == "digits"]
|
|
183
|
+
if not digit_positions:
|
|
184
|
+
raise RejectError("no ID chunks found in input")
|
|
185
|
+
|
|
186
|
+
# Expand every digit run into its 2-digit chunks (grammar step 2),
|
|
187
|
+
# rejecting any run whose length isn't a multiple of 2. Each run keeps
|
|
188
|
+
# its own ordered list of chunk dicts: {"value": "01", "tag": None}.
|
|
189
|
+
runs = [] # list of {"token_pos": i, "chunks": [chunk_dict, ...]}
|
|
190
|
+
for pos in digit_positions:
|
|
191
|
+
run = tokens[pos][1]
|
|
192
|
+
if len(run) % 2 != 0:
|
|
193
|
+
raise RejectError(
|
|
194
|
+
f"malformed digit run: '{run}' has odd length {len(run)} "
|
|
195
|
+
"(not a multiple of 2)"
|
|
196
|
+
)
|
|
197
|
+
run_chunks = [{"value": run[i:i + 2], "tag": None} for i in range(0, len(run), 2)]
|
|
198
|
+
runs.append({"token_pos": pos, "chunks": run_chunks})
|
|
199
|
+
|
|
200
|
+
run_by_token_pos = {r["token_pos"]: r for r in runs}
|
|
201
|
+
|
|
202
|
+
# Tag assignment (grammar step 3), one pass over LETTER tokens. A tag
|
|
203
|
+
# letter run must be a SINGLE E/S/T letter — a multi-letter run, or a
|
|
204
|
+
# letter that isn't E/S/T, is not a valid tag and is ignored (treated
|
|
205
|
+
# as noise, not a rejection).
|
|
206
|
+
#
|
|
207
|
+
# Adjacency priority when a single tag letter sits BETWEEN two digit
|
|
208
|
+
# runs (e.g. "E01S02" — the 'S' is simultaneously "after 01" and
|
|
209
|
+
# "before 02"): the letter tags the FOLLOWING run's FIRST chunk, never
|
|
210
|
+
# the preceding run's last chunk. This is what makes the negotiated
|
|
211
|
+
# examples parse correctly — "E01s02" reads as E=epic(01), S=story(02),
|
|
212
|
+
# not as a conflict between the two adjacencies of the same 'S'. A
|
|
213
|
+
# trailing letter with NO digit run after it (a true suffix tag, e.g.
|
|
214
|
+
# "0102T" with nothing following the T) falls back to tagging the
|
|
215
|
+
# PRECEDING run's LAST chunk, since there is no following run to prefer.
|
|
216
|
+
for i, (kind, val) in enumerate(tokens):
|
|
217
|
+
if kind != "letters" or len(val) != 1 or val not in LETTER_LEVEL:
|
|
218
|
+
continue
|
|
219
|
+
level = LETTER_LEVEL[val]
|
|
220
|
+
|
|
221
|
+
target_chunk = None
|
|
222
|
+
if i + 1 < len(tokens) and tokens[i + 1][0] == "digits":
|
|
223
|
+
target_chunk = run_by_token_pos[i + 1]["chunks"][0]
|
|
224
|
+
elif i - 1 >= 0 and tokens[i - 1][0] == "digits":
|
|
225
|
+
target_chunk = run_by_token_pos[i - 1]["chunks"][-1]
|
|
226
|
+
|
|
227
|
+
if target_chunk is None:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if target_chunk["tag"] is not None and target_chunk["tag"] != level:
|
|
231
|
+
raise RejectError(
|
|
232
|
+
f"conflicting tags around chunk '{target_chunk['value']}': "
|
|
233
|
+
f"adjacent to both {LEVEL_LETTER[target_chunk['tag']]} and {val}"
|
|
234
|
+
)
|
|
235
|
+
target_chunk["tag"] = level
|
|
236
|
+
|
|
237
|
+
chunks = [c for r in runs for c in r["chunks"]]
|
|
238
|
+
|
|
239
|
+
# Assign tagged chunks to their levels, rejecting duplicate tags for the
|
|
240
|
+
# same level (grammar step 3/4) — e.g. two separate digit runs both
|
|
241
|
+
# tagged 'E'.
|
|
242
|
+
result = {}
|
|
243
|
+
tagged_chunks = [c for c in chunks if c["tag"] is not None]
|
|
244
|
+
untagged_chunks = [c for c in chunks if c["tag"] is None]
|
|
245
|
+
|
|
246
|
+
for c in tagged_chunks:
|
|
247
|
+
level = c["tag"]
|
|
248
|
+
if level in result:
|
|
249
|
+
raise RejectError(
|
|
250
|
+
f"conflicting tags: level '{LEVEL_LETTER[level]}' tagged more than once"
|
|
251
|
+
)
|
|
252
|
+
result[level] = c["value"]
|
|
253
|
+
|
|
254
|
+
# Untagged chunks fill remaining levels positionally, epic -> story ->
|
|
255
|
+
# task, left to right, skipping levels already claimed by a tag.
|
|
256
|
+
open_levels = [lvl for lvl in LEVELS if lvl not in result]
|
|
257
|
+
if len(untagged_chunks) > len(open_levels):
|
|
258
|
+
raise RejectError(
|
|
259
|
+
f"too many untagged chunks: {len(untagged_chunks)} chunks but "
|
|
260
|
+
f"only {len(open_levels)} open level(s)"
|
|
261
|
+
)
|
|
262
|
+
for c, level in zip(untagged_chunks, open_levels):
|
|
263
|
+
result[level] = c["value"]
|
|
264
|
+
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def candidate_id_fragments(level_map):
|
|
269
|
+
"""Given {'epic': '01', 'story': '02'}, produce per-level ID fragments
|
|
270
|
+
('epic': 'E01', 'story': 'S02', ...) used for board matching."""
|
|
271
|
+
return {lvl: f"{LEVEL_LETTER[lvl]}{val}" for lvl, val in level_map.items()}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def item_levels(item_id):
|
|
275
|
+
"""Split a board id like 'E01_S02_T03' into {'epic': 'E01', ...}."""
|
|
276
|
+
parts = item_id.split("_")
|
|
277
|
+
levels = {}
|
|
278
|
+
if len(parts) >= 1 and parts[0].startswith("E"):
|
|
279
|
+
levels["epic"] = parts[0]
|
|
280
|
+
if len(parts) >= 2 and parts[1].startswith("S"):
|
|
281
|
+
levels["story"] = parts[1]
|
|
282
|
+
if len(parts) >= 3 and parts[2].startswith("T"):
|
|
283
|
+
levels["task"] = parts[2]
|
|
284
|
+
return levels
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def resolve_against_board(level_map, board):
|
|
288
|
+
"""Resolve a parsed level_map against the board inventory. Returns
|
|
289
|
+
(resolved_id, None) on unique success, or (None, reason) on failure.
|
|
290
|
+
|
|
291
|
+
The candidate's TYPE is the deepest level present in level_map (e.g.
|
|
292
|
+
{epic, story} targets a story, {epic, story, task} targets a task,
|
|
293
|
+
{story} alone still targets a story — just an under-specified one).
|
|
294
|
+
Restricting the match to items of exactly that `type` (the field
|
|
295
|
+
board-scan.sh already emits) is what keeps a story-level candidate
|
|
296
|
+
like "0103" (epic=01, story=03, no task) resolving to the STORY
|
|
297
|
+
'E01_S03' itself rather than colliding with every task underneath it —
|
|
298
|
+
without this restriction, a levels-only filter would also match
|
|
299
|
+
'E01_S03_T01', 'E01_S03_T02', etc., since they share the same
|
|
300
|
+
epic/story numbers.
|
|
301
|
+
|
|
302
|
+
For a genuinely partial candidate missing shallower levels (e.g. only
|
|
303
|
+
a story chunk with no epic tag), the same type-restricted filter
|
|
304
|
+
naturally requires uniqueness ACROSS epics, which is exactly the
|
|
305
|
+
disambiguation guarantee the grammar spec requires."""
|
|
306
|
+
if not level_map:
|
|
307
|
+
return None, "no ID chunks found in input"
|
|
308
|
+
|
|
309
|
+
frag = candidate_id_fragments(level_map)
|
|
310
|
+
deepest = max(level_map.keys(), key=LEVELS.index)
|
|
311
|
+
|
|
312
|
+
def matches(item):
|
|
313
|
+
if item.get("type") != deepest:
|
|
314
|
+
return False
|
|
315
|
+
levels = item_levels(item.get("id", ""))
|
|
316
|
+
for lvl, want in frag.items():
|
|
317
|
+
if levels.get(lvl) != want:
|
|
318
|
+
return False
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
matches_list = [item for item in board if matches(item)]
|
|
322
|
+
|
|
323
|
+
partial_desc = ", ".join(f"{lvl}={v}" for lvl, v in frag.items())
|
|
324
|
+
|
|
325
|
+
if len(matches_list) == 1:
|
|
326
|
+
return matches_list[0]["id"], None
|
|
327
|
+
if len(matches_list) == 0:
|
|
328
|
+
return None, f"not found on board: {partial_desc}"
|
|
329
|
+
|
|
330
|
+
ids = sorted(m["id"] for m in matches_list)
|
|
331
|
+
preview = ", ".join(ids[:8]) + (", ..." if len(ids) > 8 else "")
|
|
332
|
+
return None, (
|
|
333
|
+
f"ambiguous partial ID '{partial_desc}': matches {len(matches_list)} "
|
|
334
|
+
f"candidates ({preview}) - disambiguation required"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
results = []
|
|
339
|
+
any_rejected = False
|
|
340
|
+
|
|
341
|
+
segments = raw_input.split(",") if raw_input.strip() != "" else []
|
|
342
|
+
|
|
343
|
+
for raw_segment in segments:
|
|
344
|
+
entry = {"input": raw_segment, "status": None, "resolved_id": None, "reason": None}
|
|
345
|
+
try:
|
|
346
|
+
cleaned = strip_and_upper(raw_segment)
|
|
347
|
+
level_map = parse_segment(cleaned)
|
|
348
|
+
resolved_id, reason = resolve_against_board(level_map, board)
|
|
349
|
+
if resolved_id is not None:
|
|
350
|
+
entry["status"] = "resolved"
|
|
351
|
+
entry["resolved_id"] = resolved_id
|
|
352
|
+
else:
|
|
353
|
+
entry["status"] = "rejected"
|
|
354
|
+
entry["reason"] = reason
|
|
355
|
+
any_rejected = True
|
|
356
|
+
except RejectError as e:
|
|
357
|
+
entry["status"] = "rejected"
|
|
358
|
+
entry["reason"] = e.reason
|
|
359
|
+
any_rejected = True
|
|
360
|
+
results.append(entry)
|
|
361
|
+
|
|
362
|
+
print(json.dumps(results, indent=2))
|
|
363
|
+
sys.exit(1 if any_rejected else 0)
|
|
364
|
+
PY
|
|
365
|
+
|
|
366
|
+
python3 "$PY_SCRIPT" "$RAW_INPUT" <<< "$BOARD_JSON"
|
|
367
|
+
exit $?
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jenga-permission-level
|
|
3
|
+
description: Report or switch the current session's 5-tier permission level (Locked/Guarded/Standard/Elevated/Unrestricted) without hand-editing settings.json.
|
|
4
|
+
keywords:
|
|
5
|
+
- "permission level"
|
|
6
|
+
- "permission"
|
|
7
|
+
- "session level"
|
|
8
|
+
- "jenga permission level"
|
|
9
|
+
examples:
|
|
10
|
+
- "what permission level am I at?"
|
|
11
|
+
- "switch to permission level 4"
|
|
12
|
+
- "set permission level to elevated"
|
|
13
|
+
- "lock down permissions"
|
|
14
|
+
- "unlock full permissions for this session"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# jenga-permission-level — Report or Switch Session Permission Level
|
|
18
|
+
|
|
19
|
+
## Purpose
|
|
20
|
+
|
|
21
|
+
`/jenga-permission-level [1-5]` reports or switches the current session's permission level:
|
|
22
|
+
|
|
23
|
+
| Level | Name | Notes |
|
|
24
|
+
|-------|------|-------|
|
|
25
|
+
| 1 | Locked | |
|
|
26
|
+
| 2 | Guarded | Conceptual default — always reported as `Default: 2`, no matter the current session level. |
|
|
27
|
+
| 3 | Standard | |
|
|
28
|
+
| 4 | Elevated | |
|
|
29
|
+
| 5 | Unrestricted | |
|
|
30
|
+
|
|
31
|
+
`defaultMode` stays `acceptEdits` at every level; only the `permissions` block changes. Levels are backed by template files at `templates/permission-levels/level-<n>-<name>.json`, e.g. `templates/permission-levels/level-4-elevated.json` (naming convention: `<name>` is the lowercase level name from the table above — Locked/Guarded/Standard/Elevated/Unrestricted). Those templates are owned by story E33_S01 and are the input the switch script (see below) copies from.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Instructions
|
|
36
|
+
|
|
37
|
+
### 1. No argument — report status
|
|
38
|
+
|
|
39
|
+
Read `.jenga-permission-level.json` at the repo root. Treat a missing file, or a missing/unparseable `session_level` field, as `session_level: 2`.
|
|
40
|
+
|
|
41
|
+
Run:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
n=$(jq -r '.session_level // 2' .jenga-permission-level.json 2>/dev/null || echo 2)
|
|
45
|
+
echo "Default: 2 / Session: $n"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Print exactly `Default: 2 / Session: <n>` (e.g. `Default: 2 / Session: 4`). Do not write any files in this path.
|
|
49
|
+
|
|
50
|
+
### 2. Argument `<n>` provided — validate
|
|
51
|
+
|
|
52
|
+
Before doing anything else, validate the argument:
|
|
53
|
+
|
|
54
|
+
- It must match `^[1-5]$` (a single digit, 1 through 5).
|
|
55
|
+
- If it does not match — including non-numeric input, decimals, negative numbers, `0`, or `6` and above — **stop immediately**, print a clear error (e.g. `Error: permission level must be an integer between 1 and 5, got '<input>'`), and make **no file changes**. Do not invoke the switch script.
|
|
56
|
+
|
|
57
|
+
### 3. Argument `<n>` valid — delegate to the switch script
|
|
58
|
+
|
|
59
|
+
Do not inline the copy/update logic here — per this repo's scripts-over-inline-logic convention, the switch is owned entirely by a dedicated script:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
bash scripts/jenga-permission-level-switch.sh <n>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This script (created by a separate task, E33_S02_T02) is responsible for:
|
|
66
|
+
- Copying `templates/permission-levels/level-<n>-<name>.json` over both `.claude/settings.json` and `.agents/settings.json` as a **whole-file overwrite** — not a merge of the `permissions` block alone. Two fields vary by level and are expected to change on a switch: `permissions.deny` and `autoMode.allow`. All 5 templates carry byte-identical `defaultMode`, `env`, `hooks`, and `permissions.allow`; that invariant is what keeps the overwrite safe. Two consequences follow: any top-level key present in a destination file but absent from the templates is **silently dropped** by a switch, and any future per-level difference in `defaultMode`/`env`/`hooks` would take effect without warning. Keep the templates in sync with root `settings.json` for everything except those two varying fields.
|
|
67
|
+
- Creating or updating `.jenga-permission-level.json` at the repo root to `{"session_level": <n>}`.
|
|
68
|
+
|
|
69
|
+
Relay the script's stdout to the user and honor its exit code:
|
|
70
|
+
- Exit code `0` — report success, e.g. `Switched to level <n> (<name>).`
|
|
71
|
+
- Non-zero exit code — report the script's error output verbatim and treat the switch as failed; do not claim success.
|
|
72
|
+
|
|
73
|
+
If `scripts/jenga-permission-level-switch.sh` does not exist yet (e.g. E33_S02_T02 has not landed), report that clearly as a missing dependency rather than attempting to reimplement its logic inline.
|
|
74
|
+
|
|
75
|
+
### Session End
|
|
76
|
+
|
|
77
|
+
When this skill's session concludes, emit the following signal on its own line so the Jenga Router clears the active session:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
[JENGA:SESSION_END:jenga-permission-level]
|
|
81
|
+
```
|
package/skills/proceed/SKILL.md
CHANGED
|
@@ -23,7 +23,7 @@ metadata:
|
|
|
23
23
|
|
|
24
24
|
3. **Determine the next action**:
|
|
25
25
|
- If there are tasks in `Pending` or `In Progress` status that have not yet been assigned to the developer, identify them.
|
|
26
|
-
- If outstanding tasks are ready for implementation, write a session handoff to `project/queue
|
|
26
|
+
- If outstanding tasks are ready for implementation, write a session handoff to `project/queue/handoffs/scrum-master-<session_id>-<task_id>.json` (per-session path — see `templates/SCRUM_BOARD_SCHEMA.md`'s `handoffs/` section; use the first task ID, or `batch` if several) with `"status": "planning_complete"` so that `on_session_end.sh` routes them to the developer queue.
|
|
27
27
|
- If all tasks are complete, check for epic/story rollup and update board statuses accordingly.
|
|
28
28
|
|
|
29
29
|
4. **Report** a clear summary to the user: what is done, what is in progress, what is next — and which agent will handle it.
|
package/skills/publish/SKILL.md
CHANGED
|
@@ -77,7 +77,7 @@ If config or env validation fails, the skill exits with code `4` and does not co
|
|
|
77
77
|
| `/publish setup` | Prepare or refresh target configuration | `skills/publish/scripts/setup_wizard.sh` | Supported types: `mobile-ios`, `npm`, `npm-ci`, `droplet` |
|
|
78
78
|
| `/publish deploy` | Run the full 11-step deploy orchestration | `skills/publish/scripts/publish_deploy.sh` | Dispatches to the adapter for the target's `type` (`mobile-ios`, `npm`, `npm-ci`, or `droplet`); `--dry-run` is honoured end-to-end |
|
|
79
79
|
| `/publish history` | Read the canonical publish ledger | `skills/publish/scripts/show_history.sh` | Target-agnostic; filter by `--target <name>` |
|
|
80
|
-
| `/publish release-notes` |
|
|
80
|
+
| `/publish release-notes` | Merge new release notes into the standing `CHANGELOG.md` (or a standalone draft via `--output`) without publishing | `skills/publish/scripts/generate_release_notes.sh` | Target-agnostic |
|
|
81
81
|
|
|
82
82
|
## Quality Gate Policy
|
|
83
83
|
|
|
@@ -131,7 +131,7 @@ Example invocations:
|
|
|
131
131
|
### `/publish deploy`
|
|
132
132
|
|
|
133
133
|
```text
|
|
134
|
-
/publish deploy [--target <name>] [--config <path>] [--yes] [--dry-run] [--minor | --major] [--
|
|
134
|
+
/publish deploy [--target <name>] [--config <path>] [--yes] [--dry-run] [--minor | --major] [--notes-file <path>]
|
|
135
135
|
```
|
|
136
136
|
|
|
137
137
|
Implementation: `bash skills/publish/scripts/publish_deploy.sh [flags...]`
|
|
@@ -142,12 +142,12 @@ Deploy flow:
|
|
|
142
142
|
3. Validate target environment variables
|
|
143
143
|
4. Print a best-effort scrum-board summary since the last publish tag
|
|
144
144
|
5. Run pre-deploy gates
|
|
145
|
-
6.
|
|
145
|
+
6. Update the standing `CHANGELOG.md`'s `[Unreleased]` section with new entries since the last publish tag (or use the caller-supplied file instead, when release notes are supplied via the bypass flag), and review it unless `--yes` is set
|
|
146
146
|
7. Suggest and confirm the semver bump (`patch` by default in non-interactive mode unless `--minor` or `--major` is passed)
|
|
147
147
|
8. Print final confirmation: `Deploy v<x.y.z> to <target>? [y/N]`
|
|
148
148
|
9. Execute the adapter pipeline for the target's `type` (`ios_pipeline.sh` for `mobile-ios`, `npm_pipeline.sh` for `npm`, `npm_ci_pipeline.sh` for `npm-ci`, `droplet_pipeline.sh` for `droplet`)
|
|
149
149
|
10. Run post-deploy gates and downgrade the ledger state to `partial` on failure
|
|
150
|
-
11.
|
|
150
|
+
11. Finalize `CHANGELOG.md` — stamp `[Unreleased]` to a versioned, dated entry and open a fresh empty `[Unreleased]` above it (skipped on `--dry-run`, and skipped when release notes were supplied via the bypass flag, since there is then no standing-file `[Unreleased]` section to finalize) — then append the publish ledger entry, create the git tag (unless `--dry-run`), and print post-deploy manual steps
|
|
151
151
|
|
|
152
152
|
Non-interactive rule: when `--yes` is used, deploy must not auto-run setup after a failure. It exits `4` and surfaces the missing fields.
|
|
153
153
|
|
|
@@ -188,8 +188,11 @@ Implementation: `bash skills/publish/scripts/generate_release_notes.sh [--target
|
|
|
188
188
|
|
|
189
189
|
Release-note rules:
|
|
190
190
|
- The last publish tag is the highest semver tag on the current branch that also has a matching ledger entry in `project/logs/publish-history.json`.
|
|
191
|
-
-
|
|
191
|
+
- **Default target (no `--output`):** the repo-root `CHANGELOG.md` — created from `templates/CHANGELOG_TEMPLATE.md` first if it doesn't exist yet (backward-compat for projects scaffolded before this convention). New Features/Bug Fixes/Other/Completed-task entries since the last publish tag are appended under the existing `## [Unreleased]` heading's subsections; everything else in the file (prior versioned entries, manual edits) is preserved. Entries already present are deduped (matched by commit short-sha or task id), so re-running with no new commits produces zero diff.
|
|
192
|
+
- **`--output <path>`:** writes a standalone, disposable draft to that path instead (the pre-E36 behavior) — `CHANGELOG.md` is not touched in this mode.
|
|
193
|
+
- If no prior ledger-backed tag exists, a `--output` draft includes `> First release — full history included`; the standing `CHANGELOG.md` merge mode has no equivalent banner (it just merges full history into `[Unreleased]` like any other run).
|
|
192
194
|
- Scrum-board enrichment is best-effort only; missing or unreadable board data never fails the command.
|
|
195
|
+
- **`.publicignore` filtering:** when a repo-root `.publicignore` exists (the blocklist `/mirror-public` also reads), a candidate commit — or a completed task's associated commit(s), matched via the `task(E##_S##_T##):` commit-message convention — is dropped entirely when every changed file it touches is covered by that blocklist. A commit touching a mix of blocked and unblocked files is still logged normally; only full coverage excludes an entry. This exists because `CHANGELOG.md` itself is not blocklisted and ships to the public mirror, so an unfiltered entry referencing a private-only path (e.g. `project/board/`) would leak. Absent `.publicignore`, this is a strict no-op — unchanged from pre-E36_S02_T02 behavior. Matching reuses `skills/mirror-public/scripts/mirror.sh`'s `rsync --exclude-from` evaluation rather than a separate glob implementation, so a path classified "blocked" by `/mirror-public --dry-run` is classified "blocked" here too.
|
|
193
196
|
|
|
194
197
|
## Ledger & Tagging
|
|
195
198
|
|
|
@@ -18,7 +18,7 @@ Every `/publish` invocation must perform these steps before command-specific wor
|
|
|
18
18
|
| Select deploy target | `--target <name>` | `PUBLISH_TARGET` | Yes | Flag takes precedence over env var |
|
|
19
19
|
| Skip confirmation prompts | `--yes` | `PUBLISH_YES` | Yes | Env var truthy values: `1`, `true`, `yes`, `on` |
|
|
20
20
|
| Override config path | `--config <path>` | — | No | Defaults to `project/configs/publish.json` |
|
|
21
|
-
| Reuse prepared release notes | `--
|
|
21
|
+
| Reuse prepared release notes | `--notes-file <path>` | — | No | Live: bypasses generation entirely and uses the supplied file directly, instead of the standing `CHANGELOG.md` flow |
|
|
22
22
|
| Dry-run execution | `--dry-run` | `PUBLISH_DRY_RUN` | No | Prints allowlisted `xcodebuild` / `xcrun` commands without executing them |
|
|
23
23
|
|
|
24
24
|
## Required contract
|
|
@@ -58,7 +58,7 @@ These run only after the mandatory global gates succeed.
|
|
|
58
58
|
These inputs remain optional in non-interactive mode:
|
|
59
59
|
|
|
60
60
|
- `--config <path>` when the default config exists
|
|
61
|
-
- `--
|
|
61
|
+
- `--notes-file <path>`
|
|
62
62
|
- `--from-tag` and `--to-ref` on `/publish release-notes`
|
|
63
63
|
|
|
64
64
|
## Exit codes
|
|
@@ -8,7 +8,7 @@ The `/publish` skill is initiated by the operator running the CLI, but each acti
|
|
|
8
8
|
| `/publish deploy --target staging` | Initiates | May run as part of test cycle | Never directly |
|
|
9
9
|
| `/publish deploy --target production` | Initiates with explicit confirmation | Reviews ledger entry post-deploy | Reviews `/status` only |
|
|
10
10
|
| `/publish history` | Reads for context | Reads for test baseline | Reads in `/status` review |
|
|
11
|
-
| `/publish release-notes` |
|
|
11
|
+
| `/publish release-notes` | Updates and reviews CHANGELOG.md `[Unreleased]` | May read CHANGELOG.md for test context | Reviews content for sprint summary |
|
|
12
12
|
| Gate failure resolution | Owns rework | Validates rework | Escalates if blocked |
|
|
13
13
|
| Ledger entry disputes | Never modifies | Never modifies | Flags as rapport; human resolves |
|
|
14
14
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
source "$SCRIPT_DIR/publish_common.sh"
|
|
6
|
+
|
|
7
|
+
usage() {
|
|
8
|
+
cat <<'USAGE'
|
|
9
|
+
Usage: finalize_changelog.sh <version> [<changelog_path>]
|
|
10
|
+
|
|
11
|
+
Stamps the standing CHANGELOG.md's `## [Unreleased]` heading into a dated,
|
|
12
|
+
versioned heading (`## [<version>] — <date>`) and inserts a fresh, empty
|
|
13
|
+
`## [Unreleased]` section directly above it, matching
|
|
14
|
+
templates/CHANGELOG_TEMPLATE.md's subsection structure
|
|
15
|
+
(### Features / ### Bug Fixes / ### Other).
|
|
16
|
+
|
|
17
|
+
<changelog_path> defaults to <repo-root>/CHANGELOG.md.
|
|
18
|
+
|
|
19
|
+
This is a one-shot finalize step, meant to run once per confirmed deploy,
|
|
20
|
+
after the semver bump is confirmed and before the release is recorded to
|
|
21
|
+
the publish ledger. It does not merge or classify entries — that is
|
|
22
|
+
generate_release_notes.sh's job, run earlier in the deploy pipeline.
|
|
23
|
+
USAGE
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
[[ $# -ge 1 ]] || { usage >&2; exit 1; }
|
|
27
|
+
VERSION_RAW="$1"
|
|
28
|
+
CHANGELOG_PATH="${2:-$PUBLISH_REPO_ROOT/CHANGELOG.md}"
|
|
29
|
+
|
|
30
|
+
VERSION="$(publish_normalize_version "$VERSION_RAW")" || {
|
|
31
|
+
echo "finalize_changelog.sh: invalid version '$VERSION_RAW'." >&2
|
|
32
|
+
exit 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[[ -f "$CHANGELOG_PATH" ]] || {
|
|
36
|
+
echo "finalize_changelog.sh: changelog not found at '$CHANGELOG_PATH'." >&2
|
|
37
|
+
exit 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
TODAY="$(date -u +"%Y-%m-%d")"
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Plain indexed-array line editing, matching generate_release_notes.sh's
|
|
44
|
+
# bash 3.2 convention (no mapfile/declare -A/local -n; the stock macOS
|
|
45
|
+
# default shell lacks all three). Zero-element array expansions are guarded
|
|
46
|
+
# with explicit count checks before use under `set -u`, same as that file.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
CL_LINES=()
|
|
50
|
+
|
|
51
|
+
_cl_load() {
|
|
52
|
+
CL_LINES=()
|
|
53
|
+
local line
|
|
54
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
55
|
+
CL_LINES+=("$line")
|
|
56
|
+
done < "$1"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_cl_save() {
|
|
60
|
+
if (( ${#CL_LINES[@]} > 0 )); then
|
|
61
|
+
printf '%s\n' "${CL_LINES[@]}" > "$1"
|
|
62
|
+
else
|
|
63
|
+
: > "$1"
|
|
64
|
+
fi
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_cl_load "$CHANGELOG_PATH"
|
|
68
|
+
|
|
69
|
+
UNRELEASED_INDEX=-1
|
|
70
|
+
CL_LINE_COUNT=${#CL_LINES[@]}
|
|
71
|
+
i=0
|
|
72
|
+
while (( i < CL_LINE_COUNT )); do
|
|
73
|
+
if [[ "${CL_LINES[$i]}" == "## [Unreleased]" ]]; then
|
|
74
|
+
UNRELEASED_INDEX=$i
|
|
75
|
+
break
|
|
76
|
+
fi
|
|
77
|
+
i=$((i + 1))
|
|
78
|
+
done
|
|
79
|
+
|
|
80
|
+
if (( UNRELEASED_INDEX < 0 )); then
|
|
81
|
+
echo "finalize_changelog.sh: '## [Unreleased]' section not found in $CHANGELOG_PATH — nothing to finalize." >&2
|
|
82
|
+
exit 1
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Stamp the located heading in place.
|
|
86
|
+
CL_LINES[UNRELEASED_INDEX]="## [$VERSION] — $TODAY"
|
|
87
|
+
|
|
88
|
+
BEFORE=()
|
|
89
|
+
AFTER=()
|
|
90
|
+
if (( UNRELEASED_INDEX > 0 )); then
|
|
91
|
+
BEFORE=("${CL_LINES[@]:0:UNRELEASED_INDEX}")
|
|
92
|
+
fi
|
|
93
|
+
AFTER=("${CL_LINES[@]:UNRELEASED_INDEX}")
|
|
94
|
+
|
|
95
|
+
FRESH_UNRELEASED=(
|
|
96
|
+
"## [Unreleased]"
|
|
97
|
+
""
|
|
98
|
+
"### Features"
|
|
99
|
+
""
|
|
100
|
+
"### Bug Fixes"
|
|
101
|
+
""
|
|
102
|
+
"### Other"
|
|
103
|
+
""
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
CL_LINES=()
|
|
107
|
+
if (( ${#BEFORE[@]} > 0 )); then
|
|
108
|
+
CL_LINES+=("${BEFORE[@]}")
|
|
109
|
+
fi
|
|
110
|
+
CL_LINES+=("${FRESH_UNRELEASED[@]}")
|
|
111
|
+
CL_LINES+=("${AFTER[@]}")
|
|
112
|
+
|
|
113
|
+
_cl_save "$CHANGELOG_PATH"
|
|
114
|
+
|
|
115
|
+
printf '🏷️ CHANGELOG.md finalized: [Unreleased] stamped as [%s] — %s\n' "$VERSION" "$TODAY"
|