@kafka0102/onespec 0.1.2
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 +84 -0
- package/assets/skills/onespec/SKILL.md +58 -0
- package/assets/skills/onespec/scripts/onespec-closeout.sh +404 -0
- package/assets/skills/onespec/scripts/onespec-commit.sh +444 -0
- package/assets/skills/onespec/scripts/onespec-env.sh +15 -0
- package/assets/skills/onespec/scripts/onespec-handoff.sh +115 -0
- package/assets/skills/onespec/scripts/onespec-state.sh +341 -0
- package/assets/skills/onespec-archive/SKILL.md +202 -0
- package/assets/skills/onespec-design/SKILL.md +226 -0
- package/assets/skills/onespec-execute/SKILL.md +219 -0
- package/assets/skills-en/onespec/SKILL.md +57 -0
- package/assets/skills-en/onespec-archive/SKILL.md +199 -0
- package/assets/skills-en/onespec-design/SKILL.md +226 -0
- package/assets/skills-en/onespec-execute/SKILL.md +219 -0
- package/bin/onespec.js +7 -0
- package/package.json +38 -0
- package/scripts/postinstall.js +28 -0
- package/src/cli.js +244 -0
- package/src/doctor.js +172 -0
- package/src/init.js +136 -0
- package/src/platforms.js +23 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
die() {
|
|
5
|
+
echo "ERROR: $*" >&2
|
|
6
|
+
exit 1
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
valid_change() {
|
|
10
|
+
local change="$1"
|
|
11
|
+
[[ -n "$change" ]] || die "change name is required"
|
|
12
|
+
[[ "$change" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid change name: $change"
|
|
13
|
+
[[ "$change" != *".."* ]] || die "change name must not contain '..'"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
change_dir() {
|
|
17
|
+
local change="$1"
|
|
18
|
+
if [ -d "openspec/changes/$change" ]; then
|
|
19
|
+
printf 'openspec/changes/%s\n' "$change"
|
|
20
|
+
elif [ -d "openspec/changes/archive/$change" ]; then
|
|
21
|
+
printf 'openspec/changes/archive/%s\n' "$change"
|
|
22
|
+
else
|
|
23
|
+
printf 'openspec/changes/%s\n' "$change"
|
|
24
|
+
fi
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
state_file() {
|
|
28
|
+
local change="$1"
|
|
29
|
+
printf '%s/.onespec.yaml\n' "$(change_dir "$change")"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
normalize_path() {
|
|
33
|
+
local input="$1"
|
|
34
|
+
local normalized="${input#./}"
|
|
35
|
+
[[ -n "$normalized" ]] || die "path must not be empty"
|
|
36
|
+
[[ "$normalized" != .*"/../"* ]] || die "path must not contain parent traversal: $input"
|
|
37
|
+
[[ "$normalized" != ../* ]] || die "path must not contain parent traversal: $input"
|
|
38
|
+
[[ "$normalized" != *"/.." ]] || die "path must not contain parent traversal: $input"
|
|
39
|
+
printf '%s\n' "$normalized"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
sort_unique_lines() {
|
|
43
|
+
awk 'NF && !seen[$0]++ { print $0 }'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
field_value() {
|
|
47
|
+
local file="$1"
|
|
48
|
+
local key="$2"
|
|
49
|
+
awk -F ': *' -v key="$key" '$1 == key { sub(/^[^:]+: */, ""); print; found=1; exit } END { if (!found) exit 0 }' "$file" 2>/dev/null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
set_field() {
|
|
53
|
+
local file="$1"
|
|
54
|
+
local key="$2"
|
|
55
|
+
local value="$3"
|
|
56
|
+
local tmp
|
|
57
|
+
tmp="$(mktemp)"
|
|
58
|
+
if grep -q "^${key}:" "$file"; then
|
|
59
|
+
awk -v key="$key" -v value="$value" '
|
|
60
|
+
$0 ~ "^" key ":" { print key ": " value; next }
|
|
61
|
+
{ print }
|
|
62
|
+
' "$file" > "$tmp"
|
|
63
|
+
else
|
|
64
|
+
cat "$file" > "$tmp"
|
|
65
|
+
printf '%s: %s\n' "$key" "$value" >> "$tmp"
|
|
66
|
+
fi
|
|
67
|
+
mv "$tmp" "$file"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
encode_base64() {
|
|
71
|
+
base64 | tr -d '\n'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
decode_base64() {
|
|
75
|
+
if base64 --help >/dev/null 2>&1; then
|
|
76
|
+
base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D
|
|
77
|
+
else
|
|
78
|
+
base64 -d 2>/dev/null || base64 -D
|
|
79
|
+
fi
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
load_tracked_lines() {
|
|
83
|
+
local change="$1"
|
|
84
|
+
local file encoded
|
|
85
|
+
file="$(state_file "$change")"
|
|
86
|
+
[ -f "$file" ] || return 0
|
|
87
|
+
encoded="$(field_value "$file" touched_files_b64)"
|
|
88
|
+
if [ -z "$encoded" ] || [ "$encoded" = "null" ]; then
|
|
89
|
+
return 0
|
|
90
|
+
fi
|
|
91
|
+
printf '%s' "$encoded" | decode_base64
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
save_tracked_lines() {
|
|
95
|
+
local change="$1"
|
|
96
|
+
local file="$2"
|
|
97
|
+
local state encoded
|
|
98
|
+
state="$(state_file "$change")"
|
|
99
|
+
[ -f "$state" ] || die "state not found: $state"
|
|
100
|
+
if [ ! -s "$file" ]; then
|
|
101
|
+
set_field "$state" touched_files_b64 "null"
|
|
102
|
+
return 0
|
|
103
|
+
fi
|
|
104
|
+
encoded="$(encode_base64 < "$file")"
|
|
105
|
+
set_field "$state" touched_files_b64 "$encoded"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
ensure_git_repo() {
|
|
109
|
+
git rev-parse --show-toplevel >/dev/null 2>&1 || die "current directory is not inside a git repository"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
git_dirty_paths() {
|
|
113
|
+
git status --porcelain=v1 --untracked-files=all | awk '
|
|
114
|
+
{
|
|
115
|
+
path = substr($0, 4)
|
|
116
|
+
sub(/^.* -> /, "", path)
|
|
117
|
+
if (length(path) > 0) {
|
|
118
|
+
print path
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dirty_change_artifact_paths() {
|
|
125
|
+
local change="$1"
|
|
126
|
+
local dir
|
|
127
|
+
dir="$(change_dir "$change")"
|
|
128
|
+
git_dirty_paths | awk -v prefix="$dir/" 'index($0, prefix) == 1 { print }'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
repo_layout() {
|
|
132
|
+
if [ -f "pnpm-workspace.yaml" ] || [ -f "nx.json" ] || [ -f "turbo.json" ] || [ -f "lerna.json" ] || [ -f "go.work" ] || [ -f "settings.gradle" ] || [ -f "settings.gradle.kts" ]; then
|
|
133
|
+
echo "multi"
|
|
134
|
+
return 0
|
|
135
|
+
fi
|
|
136
|
+
if [ -f "package.json" ] && grep -Eq '"workspaces"[[:space:]]*:' package.json; then
|
|
137
|
+
echo "multi"
|
|
138
|
+
return 0
|
|
139
|
+
fi
|
|
140
|
+
if [ -f "Cargo.toml" ] && grep -Eq '^\[workspace\]' Cargo.toml; then
|
|
141
|
+
echo "multi"
|
|
142
|
+
return 0
|
|
143
|
+
fi
|
|
144
|
+
if [ -f "pom.xml" ] && grep -Eq '<modules>' pom.xml; then
|
|
145
|
+
echo "multi"
|
|
146
|
+
return 0
|
|
147
|
+
fi
|
|
148
|
+
echo "single"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
find_policy_doc() {
|
|
152
|
+
local pattern='提交|commit message|commit messages|提交信息|提交规范|提交格式|conventional commit|conventional commits|git workflow|commitlint|commitizen'
|
|
153
|
+
local file
|
|
154
|
+
|
|
155
|
+
for file in AGENTS.md CONTRIBUTING.md CONTRIBUTING.zh-CN.md README.md README-zh.md README.en.md; do
|
|
156
|
+
if [ -f "$file" ] && grep -Eiq "$pattern" "$file"; then
|
|
157
|
+
printf '%s\n' "$file"
|
|
158
|
+
return 0
|
|
159
|
+
fi
|
|
160
|
+
done
|
|
161
|
+
|
|
162
|
+
if [ -d docs ]; then
|
|
163
|
+
while IFS= read -r file; do
|
|
164
|
+
if grep -Eiq "$pattern" "$file"; then
|
|
165
|
+
printf '%s\n' "$file"
|
|
166
|
+
return 0
|
|
167
|
+
fi
|
|
168
|
+
done < <(find docs -type f \( -name '*.md' -o -name '*.txt' \) | sort)
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
return 1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
find_commit_config() {
|
|
175
|
+
local file
|
|
176
|
+
|
|
177
|
+
for file in \
|
|
178
|
+
commitlint.config.js \
|
|
179
|
+
commitlint.config.cjs \
|
|
180
|
+
commitlint.config.mjs \
|
|
181
|
+
commitlint.config.ts \
|
|
182
|
+
.commitlintrc \
|
|
183
|
+
.commitlintrc.json \
|
|
184
|
+
.commitlintrc.yml \
|
|
185
|
+
.commitlintrc.yaml \
|
|
186
|
+
.commitlintrc.js \
|
|
187
|
+
.commitlintrc.cjs \
|
|
188
|
+
.czrc
|
|
189
|
+
do
|
|
190
|
+
if [ -f "$file" ]; then
|
|
191
|
+
printf '%s\n' "$file"
|
|
192
|
+
return 0
|
|
193
|
+
fi
|
|
194
|
+
done
|
|
195
|
+
|
|
196
|
+
if [ -f "package.json" ] && grep -Eiq '"(commitlint|commitizen)"[[:space:]]*:' package.json; then
|
|
197
|
+
echo "package.json"
|
|
198
|
+
return 0
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
return 1
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
detect_language_from_doc() {
|
|
205
|
+
local file="$1"
|
|
206
|
+
|
|
207
|
+
if grep -Eiq '简体中文|中文 conventional|中文提交|提交标题.*中文|描述.*中文' "$file"; then
|
|
208
|
+
echo "zh"
|
|
209
|
+
return 0
|
|
210
|
+
fi
|
|
211
|
+
if grep -Eiq 'english|commit message.*english|description.*english' "$file"; then
|
|
212
|
+
echo "en"
|
|
213
|
+
return 0
|
|
214
|
+
fi
|
|
215
|
+
if grep -q '[一-龥]' "$file"; then
|
|
216
|
+
echo "zh"
|
|
217
|
+
return 0
|
|
218
|
+
fi
|
|
219
|
+
echo "unknown"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
detect_format_from_file() {
|
|
223
|
+
local file="$1"
|
|
224
|
+
|
|
225
|
+
if grep -Eiq '<type>\(<scope>\):|conventional commit|conventional commits|commitlint|type\(scope\)' "$file"; then
|
|
226
|
+
echo "conventional"
|
|
227
|
+
return 0
|
|
228
|
+
fi
|
|
229
|
+
echo "unknown"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
infer_scope() {
|
|
233
|
+
local change="${1:-}"
|
|
234
|
+
local tracked
|
|
235
|
+
|
|
236
|
+
if [ -z "$change" ]; then
|
|
237
|
+
echo "repo"
|
|
238
|
+
return 0
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
tracked="$(mktemp)"
|
|
242
|
+
load_tracked_lines "$change" > "$tracked"
|
|
243
|
+
if [ ! -s "$tracked" ]; then
|
|
244
|
+
rm -f "$tracked"
|
|
245
|
+
echo "repo"
|
|
246
|
+
return 0
|
|
247
|
+
fi
|
|
248
|
+
|
|
249
|
+
awk -F/ '
|
|
250
|
+
function scope_for(path, first, second) {
|
|
251
|
+
first = $1
|
|
252
|
+
second = $2
|
|
253
|
+
if (first == "packages" || first == "apps" || first == "services" || first == "libs") {
|
|
254
|
+
return second != "" ? second : "repo"
|
|
255
|
+
}
|
|
256
|
+
if (first == "docs" || first == "openspec") {
|
|
257
|
+
return "docs"
|
|
258
|
+
}
|
|
259
|
+
if (second == "") {
|
|
260
|
+
return "repo"
|
|
261
|
+
}
|
|
262
|
+
return first
|
|
263
|
+
}
|
|
264
|
+
{
|
|
265
|
+
candidate = scope_for($0)
|
|
266
|
+
seen[candidate] = 1
|
|
267
|
+
}
|
|
268
|
+
END {
|
|
269
|
+
count = 0
|
|
270
|
+
for (key in seen) {
|
|
271
|
+
choice = key
|
|
272
|
+
count++
|
|
273
|
+
}
|
|
274
|
+
if (count == 1) {
|
|
275
|
+
print choice
|
|
276
|
+
} else {
|
|
277
|
+
print "repo"
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
' "$tracked"
|
|
281
|
+
rm -f "$tracked"
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
cmd_track() {
|
|
285
|
+
local change="$1"
|
|
286
|
+
shift
|
|
287
|
+
valid_change "$change"
|
|
288
|
+
[ "$#" -gt 0 ] || die "track requires at least one path"
|
|
289
|
+
|
|
290
|
+
local tracked tmp path
|
|
291
|
+
tracked="$(mktemp)"
|
|
292
|
+
tmp="$(mktemp)"
|
|
293
|
+
|
|
294
|
+
load_tracked_lines "$change" > "$tmp"
|
|
295
|
+
|
|
296
|
+
for path in "$@"; do
|
|
297
|
+
normalize_path "$path" >> "$tmp"
|
|
298
|
+
done
|
|
299
|
+
|
|
300
|
+
sort_unique_lines < "$tmp" > "$tracked"
|
|
301
|
+
save_tracked_lines "$change" "$tracked"
|
|
302
|
+
cat "$tracked"
|
|
303
|
+
rm -f "$tmp" "$tracked"
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
cmd_tracked() {
|
|
307
|
+
local change="$1"
|
|
308
|
+
valid_change "$change"
|
|
309
|
+
load_tracked_lines "$change"
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
cmd_related_dirty() {
|
|
313
|
+
local change="$1"
|
|
314
|
+
valid_change "$change"
|
|
315
|
+
ensure_git_repo
|
|
316
|
+
|
|
317
|
+
local tracked state dirty artifacts
|
|
318
|
+
tracked="$(mktemp)"
|
|
319
|
+
dirty="$(mktemp)"
|
|
320
|
+
artifacts="$(mktemp)"
|
|
321
|
+
load_tracked_lines "$change" > "$tracked"
|
|
322
|
+
state="$(state_file "$change")"
|
|
323
|
+
git_dirty_paths | sort_unique_lines > "$dirty"
|
|
324
|
+
dirty_change_artifact_paths "$change" | sort_unique_lines > "$artifacts"
|
|
325
|
+
|
|
326
|
+
if grep -Fxq "$state" "$dirty"; then
|
|
327
|
+
printf '%s\n' "$state" >> "$tracked"
|
|
328
|
+
fi
|
|
329
|
+
|
|
330
|
+
if [ -s "$artifacts" ]; then
|
|
331
|
+
cat "$artifacts" >> "$tracked"
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
sort_unique_lines < "$tracked" > "${tracked}.sorted"
|
|
335
|
+
mv "${tracked}.sorted" "$tracked"
|
|
336
|
+
|
|
337
|
+
if [ ! -s "$tracked" ]; then
|
|
338
|
+
rm -f "$tracked" "$dirty" "$artifacts"
|
|
339
|
+
return 0
|
|
340
|
+
fi
|
|
341
|
+
|
|
342
|
+
awk 'NR==FNR { dirty[$0] = 1; next } dirty[$0] { print $0 }' "$dirty" "$tracked" | sort_unique_lines
|
|
343
|
+
rm -f "$tracked" "$dirty" "$artifacts"
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
cmd_stage_related() {
|
|
347
|
+
local change="$1"
|
|
348
|
+
valid_change "$change"
|
|
349
|
+
ensure_git_repo
|
|
350
|
+
|
|
351
|
+
local -a files=()
|
|
352
|
+
local file
|
|
353
|
+
while IFS= read -r file; do
|
|
354
|
+
files+=("$file")
|
|
355
|
+
done < <(cmd_related_dirty "$change")
|
|
356
|
+
if [ "${#files[@]}" -eq 0 ]; then
|
|
357
|
+
return 0
|
|
358
|
+
fi
|
|
359
|
+
|
|
360
|
+
git add -A -- "${files[@]}"
|
|
361
|
+
printf '%s\n' "${files[@]}"
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
cmd_detect_policy() {
|
|
365
|
+
local change="${1:-}"
|
|
366
|
+
local layout source origin format language confidence scope
|
|
367
|
+
|
|
368
|
+
layout="$(repo_layout)"
|
|
369
|
+
scope="$(infer_scope "$change")"
|
|
370
|
+
confidence="default"
|
|
371
|
+
origin="default"
|
|
372
|
+
source="default"
|
|
373
|
+
format="conventional"
|
|
374
|
+
language="en"
|
|
375
|
+
|
|
376
|
+
if source="$(find_policy_doc)"; then
|
|
377
|
+
origin="project-doc"
|
|
378
|
+
confidence="explicit"
|
|
379
|
+
format="$(detect_format_from_file "$source")"
|
|
380
|
+
language="$(detect_language_from_doc "$source")"
|
|
381
|
+
[ "$format" != "unknown" ] || format="conventional"
|
|
382
|
+
[ "$language" != "unknown" ] || language="en"
|
|
383
|
+
else
|
|
384
|
+
local config_source
|
|
385
|
+
if config_source="$(find_commit_config)"; then
|
|
386
|
+
source="$config_source"
|
|
387
|
+
origin="project-config"
|
|
388
|
+
confidence="partial"
|
|
389
|
+
format="conventional"
|
|
390
|
+
language="en"
|
|
391
|
+
fi
|
|
392
|
+
fi
|
|
393
|
+
|
|
394
|
+
cat <<EOF
|
|
395
|
+
policy_source: $source
|
|
396
|
+
policy_origin: $origin
|
|
397
|
+
policy_confidence: $confidence
|
|
398
|
+
commit_format: $format
|
|
399
|
+
message_language: $language
|
|
400
|
+
repo_layout: $layout
|
|
401
|
+
scope_hint: $scope
|
|
402
|
+
template: <type>(<scope>): <summary>
|
|
403
|
+
EOF
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
usage() {
|
|
407
|
+
cat <<'EOF'
|
|
408
|
+
用法:
|
|
409
|
+
onespec-commit.sh track <change> <path>...
|
|
410
|
+
onespec-commit.sh tracked <change>
|
|
411
|
+
onespec-commit.sh related-dirty <change>
|
|
412
|
+
onespec-commit.sh stage-related <change>
|
|
413
|
+
onespec-commit.sh detect-policy [change]
|
|
414
|
+
EOF
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
cmd="${1:-}"
|
|
418
|
+
case "$cmd" in
|
|
419
|
+
track)
|
|
420
|
+
[ "$#" -ge 3 ] || { usage; exit 2; }
|
|
421
|
+
shift
|
|
422
|
+
cmd_track "$@"
|
|
423
|
+
;;
|
|
424
|
+
tracked)
|
|
425
|
+
[ "$#" -eq 2 ] || { usage; exit 2; }
|
|
426
|
+
cmd_tracked "$2"
|
|
427
|
+
;;
|
|
428
|
+
related-dirty)
|
|
429
|
+
[ "$#" -eq 2 ] || { usage; exit 2; }
|
|
430
|
+
cmd_related_dirty "$2"
|
|
431
|
+
;;
|
|
432
|
+
stage-related)
|
|
433
|
+
[ "$#" -eq 2 ] || { usage; exit 2; }
|
|
434
|
+
cmd_stage_related "$2"
|
|
435
|
+
;;
|
|
436
|
+
detect-policy)
|
|
437
|
+
[ "$#" -le 2 ] || { usage; exit 2; }
|
|
438
|
+
cmd_detect_policy "${2:-}"
|
|
439
|
+
;;
|
|
440
|
+
*)
|
|
441
|
+
usage
|
|
442
|
+
exit 2
|
|
443
|
+
;;
|
|
444
|
+
esac
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
_onespec_env_source="${BASH_SOURCE[0]:-$0}"
|
|
4
|
+
_onespec_script_dir="$(cd "$(dirname "$_onespec_env_source")" && pwd -P)"
|
|
5
|
+
|
|
6
|
+
export ONESPEC_STATE="${ONESPEC_STATE:-${_onespec_script_dir}/onespec-state.sh}"
|
|
7
|
+
export ONESPEC_HANDOFF="${ONESPEC_HANDOFF:-${_onespec_script_dir}/onespec-handoff.sh}"
|
|
8
|
+
export ONESPEC_COMMIT="${ONESPEC_COMMIT:-${_onespec_script_dir}/onespec-commit.sh}"
|
|
9
|
+
export ONESPEC_CLOSEOUT="${ONESPEC_CLOSEOUT:-${_onespec_script_dir}/onespec-closeout.sh}"
|
|
10
|
+
export ONESPEC_BASH="${ONESPEC_BASH:-${BASH:-bash}}"
|
|
11
|
+
|
|
12
|
+
if [ ! -f "$ONESPEC_STATE" ] || [ ! -f "$ONESPEC_HANDOFF" ] || [ ! -f "$ONESPEC_COMMIT" ] || [ ! -f "$ONESPEC_CLOSEOUT" ]; then
|
|
13
|
+
echo "ERROR: OneSpec scripts are incomplete. Re-run onespec init --overwrite." >&2
|
|
14
|
+
return 1 2>/dev/null || exit 1
|
|
15
|
+
fi
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
die() {
|
|
5
|
+
echo "ERROR: $*" >&2
|
|
6
|
+
exit 1
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
hash_file() {
|
|
10
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
11
|
+
shasum -a 256 "$1" | awk '{print $1}'
|
|
12
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
13
|
+
sha256sum "$1" | awk '{print $1}'
|
|
14
|
+
else
|
|
15
|
+
die "shasum or sha256sum is required"
|
|
16
|
+
fi
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
hash_text() {
|
|
20
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
21
|
+
shasum -a 256 | awk '{print $1}'
|
|
22
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
23
|
+
sha256sum | awk '{print $1}'
|
|
24
|
+
else
|
|
25
|
+
die "shasum or sha256sum is required"
|
|
26
|
+
fi
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
json_escape() {
|
|
30
|
+
sed 's/\\/\\\\/g; s/"/\\"/g' <<<"$1"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
valid_change() {
|
|
34
|
+
local change="$1"
|
|
35
|
+
[[ -n "$change" ]] || die "change name is required"
|
|
36
|
+
[[ "$change" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid change name: $change"
|
|
37
|
+
[[ "$change" != *".."* ]] || die "change name must not contain '..'"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
source_files() {
|
|
41
|
+
for file in "$change_dir/proposal.md" "$change_dir/design.md" "$change_dir/tasks.md"; do
|
|
42
|
+
[ -f "$file" ] && printf '%s\n' "$file"
|
|
43
|
+
done
|
|
44
|
+
if [ -d "$change_dir/specs" ]; then
|
|
45
|
+
find "$change_dir/specs" -path '*/spec.md' -type f | sort
|
|
46
|
+
fi
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
context_hash() {
|
|
50
|
+
source_files | while IFS= read -r file; do
|
|
51
|
+
printf '%s %s\n' "$(hash_file "$file")" "$file"
|
|
52
|
+
done | hash_text
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
summary_text() {
|
|
56
|
+
local files count first
|
|
57
|
+
files="$(source_files)"
|
|
58
|
+
count="$(printf '%s\n' "$files" | sed '/^$/d' | wc -l | tr -d ' ')"
|
|
59
|
+
first="$(printf '%s\n' "$files" | sed -n '1p')"
|
|
60
|
+
printf '%s handoff from %s file(s); primary artifact: %s' "$purpose" "$count" "$first"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
write_excerpt() {
|
|
64
|
+
local file="$1"
|
|
65
|
+
local max_lines=100
|
|
66
|
+
local lines
|
|
67
|
+
lines="$(wc -l < "$file" | tr -d ' ')"
|
|
68
|
+
echo "## $file"
|
|
69
|
+
echo
|
|
70
|
+
echo "- sha256: $(hash_file "$file")"
|
|
71
|
+
echo "- lines: $lines"
|
|
72
|
+
echo
|
|
73
|
+
if [ "$mode" = "full" ] || [ "$lines" -le "$max_lines" ]; then
|
|
74
|
+
echo '```md'
|
|
75
|
+
cat "$file"
|
|
76
|
+
echo '```'
|
|
77
|
+
else
|
|
78
|
+
echo "[TRUNCATED: first ${max_lines} lines only]"
|
|
79
|
+
echo
|
|
80
|
+
echo '```md'
|
|
81
|
+
sed -n "1,${max_lines}p" "$file"
|
|
82
|
+
echo '```'
|
|
83
|
+
fi
|
|
84
|
+
echo
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
change="${1:-}"
|
|
88
|
+
purpose="${2:-}"
|
|
89
|
+
action="${3:-}"
|
|
90
|
+
full_flag="${4:-}"
|
|
91
|
+
|
|
92
|
+
[ "$action" = "--write" ] || die "usage: onespec-handoff.sh <change> <purpose> --write [--full]"
|
|
93
|
+
valid_change "$change"
|
|
94
|
+
case "$purpose" in proposal|plan|review|archive) ;; *) die "purpose must be proposal, plan, review, or archive" ;; esac
|
|
95
|
+
case "$full_flag" in "" ) mode="compact" ;; "--full" ) mode="full" ;; * ) die "unknown option: $full_flag" ;; esac
|
|
96
|
+
|
|
97
|
+
change_dir="openspec/changes/$change"
|
|
98
|
+
state="$change_dir/.onespec.yaml"
|
|
99
|
+
[ -d "$change_dir" ] || die "change directory not found: $change_dir"
|
|
100
|
+
[ -f "$state" ] || die "state file not found: $state"
|
|
101
|
+
|
|
102
|
+
if [ "$(source_files | wc -l | tr -d ' ')" -eq 0 ]; then
|
|
103
|
+
die "no OpenSpec artifacts found under $change_dir"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
hash="$(context_hash)"
|
|
107
|
+
summary="$(summary_text)"
|
|
108
|
+
|
|
109
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd -P)"
|
|
110
|
+
"${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_context "$state"
|
|
111
|
+
"${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_purpose "$purpose"
|
|
112
|
+
"${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_summary "$summary"
|
|
113
|
+
"${BASH:-bash}" "$script_dir/onespec-state.sh" set "$change" handoff_hash "$hash"
|
|
114
|
+
|
|
115
|
+
echo "$state"
|