workflow-ledger 0.3.5
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/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +18 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/README.zh-CN.md +224 -0
- package/bin/workflow-ledger +417 -0
- package/bin/workflow-ledger.js +239 -0
- package/docs/cli.md +94 -0
- package/docs/design.md +43 -0
- package/docs/design.zh-CN.md +47 -0
- package/docs/usage.md +119 -0
- package/docs/usage.zh-CN.md +117 -0
- package/examples/claude-project/CLAUDE.md.snippet +18 -0
- package/examples/claude-project/WORKFLOW.md +36 -0
- package/examples/codex-project/AGENTS.md.snippet +11 -0
- package/hooks/hooks.json +10 -0
- package/hooks/session-start +28 -0
- package/install.sh +75 -0
- package/package.json +43 -0
- package/skills/workflow-ledger/SKILL.md +168 -0
- package/skills/workflow-ledger/templates/WORKFLOW.md +50 -0
- package/templates/WORKFLOW.md +50 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -u
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
if [ -n "${WORKFLOW_LEDGER_ROOT:-}" ]; then
|
|
6
|
+
ROOT="$WORKFLOW_LEDGER_ROOT"
|
|
7
|
+
elif [ "$(basename "$SCRIPT_DIR")" = "bin" ] && [ "$(basename "$(dirname "$SCRIPT_DIR")")" = ".claude" ]; then
|
|
8
|
+
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
9
|
+
else
|
|
10
|
+
ROOT="$PWD"
|
|
11
|
+
fi
|
|
12
|
+
LEDGER="$ROOT/.claude/WORKFLOW.md"
|
|
13
|
+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
14
|
+
|
|
15
|
+
error_count=0
|
|
16
|
+
warning_count=0
|
|
17
|
+
|
|
18
|
+
print_help() {
|
|
19
|
+
cat <<'HELP'
|
|
20
|
+
workflow-ledger — lightweight project-local workflow guardrails
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
workflow-ledger help
|
|
24
|
+
workflow-ledger init
|
|
25
|
+
workflow-ledger doctor
|
|
26
|
+
workflow-ledger list
|
|
27
|
+
workflow-ledger hooks status
|
|
28
|
+
workflow-ledger hooks install
|
|
29
|
+
|
|
30
|
+
Exit codes:
|
|
31
|
+
help: always 0
|
|
32
|
+
init: 0 on created/already exists/guidance, 1 on filesystem errors
|
|
33
|
+
doctor: 0 when no errors, 1 when errors exist
|
|
34
|
+
list: 0 for readable output or missing ledger, 1 when ledger exists but cannot be read
|
|
35
|
+
hooks status: 0 when status is reported, 1 on filesystem errors
|
|
36
|
+
hooks install: 0 when installed or no-op, 1 on filesystem errors
|
|
37
|
+
HELP
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
say_error() {
|
|
41
|
+
error_count=$((error_count + 1))
|
|
42
|
+
printf 'ERROR: %s\n' "$1"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
say_warning() {
|
|
46
|
+
warning_count=$((warning_count + 1))
|
|
47
|
+
printf 'WARNING: %s\n' "$1"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
say_info() {
|
|
51
|
+
printf 'INFO: %s\n' "$1"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
section_exists() {
|
|
55
|
+
local name="$1"
|
|
56
|
+
grep -Eq "^##[[:space:]]+$name[[:space:]]*$" "$LEDGER"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
hook_status_value() {
|
|
60
|
+
local hooks_json="$ROOT/.claude/hooks/hooks.json"
|
|
61
|
+
local hook_script="$ROOT/.claude/hooks/session-start"
|
|
62
|
+
if [ ! -f "$hooks_json" ] || [ ! -f "$hook_script" ]; then
|
|
63
|
+
printf 'not installed'
|
|
64
|
+
return 0
|
|
65
|
+
fi
|
|
66
|
+
if grep -Fq 'SessionStart' "$hooks_json" && grep -Fq '.claude/hooks/session-start' "$hooks_json" && [ -x "$hook_script" ]; then
|
|
67
|
+
printf 'installed'
|
|
68
|
+
else
|
|
69
|
+
printf 'incomplete'
|
|
70
|
+
fi
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
find_template() {
|
|
74
|
+
local candidates=(
|
|
75
|
+
"$ROOT/.claude/skills/workflow-ledger/templates/WORKFLOW.md"
|
|
76
|
+
"$REPO_ROOT/skills/workflow-ledger/templates/WORKFLOW.md"
|
|
77
|
+
)
|
|
78
|
+
local candidate
|
|
79
|
+
for candidate in "${candidates[@]}"; do
|
|
80
|
+
if [ -f "$candidate" ]; then
|
|
81
|
+
printf '%s' "$candidate"
|
|
82
|
+
return 0
|
|
83
|
+
fi
|
|
84
|
+
done
|
|
85
|
+
return 1
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cmd_init() {
|
|
89
|
+
if ! mkdir -p "$ROOT/.claude" 2>/dev/null; then
|
|
90
|
+
printf 'error: cannot create .claude directory\n' >&2
|
|
91
|
+
return 1
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
if [ -f "$LEDGER" ]; then
|
|
95
|
+
printf 'kept existing .claude/WORKFLOW.md\n'
|
|
96
|
+
else
|
|
97
|
+
local template
|
|
98
|
+
if ! template="$(find_template)"; then
|
|
99
|
+
printf 'workflow-ledger skill template not found; run install.sh from the workflow-ledger checkout.\n' >&2
|
|
100
|
+
return 0
|
|
101
|
+
fi
|
|
102
|
+
if ! cp "$template" "$LEDGER" 2>/dev/null; then
|
|
103
|
+
printf 'error: cannot create .claude/WORKFLOW.md\n' >&2
|
|
104
|
+
return 1
|
|
105
|
+
fi
|
|
106
|
+
printf 'created .claude/WORKFLOW.md\n'
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
if [ ! -d "$ROOT/.claude/skills/workflow-ledger" ]; then
|
|
110
|
+
printf 'note: .claude/skills/workflow-ledger is missing; run install.sh to install the skill.\n' >&2
|
|
111
|
+
fi
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
cmd_doctor() {
|
|
116
|
+
error_count=0
|
|
117
|
+
warning_count=0
|
|
118
|
+
|
|
119
|
+
if [ ! -f "$LEDGER" ]; then
|
|
120
|
+
say_error '.claude/WORKFLOW.md is missing.'
|
|
121
|
+
return 1
|
|
122
|
+
fi
|
|
123
|
+
if [ ! -r "$LEDGER" ]; then
|
|
124
|
+
say_error '.claude/WORKFLOW.md exists but cannot be read.'
|
|
125
|
+
return 1
|
|
126
|
+
fi
|
|
127
|
+
|
|
128
|
+
section_exists 'Active' || say_error 'Missing ## Active section.'
|
|
129
|
+
section_exists 'Backlog / Future' || say_error 'Missing ## Backlog / Future section.'
|
|
130
|
+
section_exists 'Completed' || say_error 'Missing ## Completed section.'
|
|
131
|
+
|
|
132
|
+
local active_count=0
|
|
133
|
+
local backlog_items=0
|
|
134
|
+
local completed_items=0
|
|
135
|
+
local hook_status
|
|
136
|
+
hook_status="$(hook_status_value)"
|
|
137
|
+
|
|
138
|
+
local in_active=0 in_backlog=0 in_completed=0
|
|
139
|
+
local task_title='' task_status='' task_level='' task_current=''
|
|
140
|
+
local task_has_intent=0 task_has_todo=0 task_has_changes=0 task_has_prereq=0 task_has_resume=0 task_has_blocked_by=0 task_has_close_summary=0
|
|
141
|
+
local task_line_count=0
|
|
142
|
+
|
|
143
|
+
finish_task() {
|
|
144
|
+
if [ -z "$task_title" ]; then
|
|
145
|
+
return 0
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
if [ "$task_status" = 'In Progress' ]; then
|
|
149
|
+
[ -n "$task_current" ] || say_error "In Progress task '$task_title' lacks Current phase."
|
|
150
|
+
[ "$task_has_intent" -eq 1 ] || say_error "In Progress task '$task_title' lacks Intent."
|
|
151
|
+
[ "$task_has_todo" -eq 1 ] || say_error "In Progress task '$task_title' lacks Current todo."
|
|
152
|
+
[ "$task_has_resume" -eq 1 ] || say_error "In Progress task '$task_title' lacks Resume next."
|
|
153
|
+
if [ "$task_level" = '2' ] || [ "$task_level" = '3' ]; then
|
|
154
|
+
[ "$task_has_changes" -eq 1 ] || say_warning "Level $task_level task '$task_title' lacks Changes."
|
|
155
|
+
[ "$task_has_prereq" -eq 1 ] || say_warning "Level $task_level task '$task_title' lacks Prerequisites."
|
|
156
|
+
fi
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
if [ "$task_status" = 'Blocked' ]; then
|
|
160
|
+
[ "$task_has_blocked_by" -eq 1 ] || say_error "Blocked task '$task_title' lacks Blocked by."
|
|
161
|
+
[ "$task_has_resume" -eq 1 ] || say_error "Blocked task '$task_title' lacks Resume next."
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
if [ "$task_status" = 'Done' ] || [ "$task_status" = 'Completed' ]; then
|
|
165
|
+
[ "$task_has_close_summary" -eq 1 ] || say_warning "Completed task '$task_title' is still under Active and lacks Close summary. Move it to ## Completed when closing."
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
if [ "$task_line_count" -gt 80 ]; then
|
|
169
|
+
say_warning "Task '$task_title' has more than 80 lines."
|
|
170
|
+
fi
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
174
|
+
case "$line" in
|
|
175
|
+
'## Active')
|
|
176
|
+
finish_task
|
|
177
|
+
in_active=1; in_backlog=0; in_completed=0
|
|
178
|
+
task_title=''
|
|
179
|
+
continue
|
|
180
|
+
;;
|
|
181
|
+
'## Backlog / Future')
|
|
182
|
+
finish_task
|
|
183
|
+
in_active=0; in_backlog=1; in_completed=0
|
|
184
|
+
task_title=''
|
|
185
|
+
continue
|
|
186
|
+
;;
|
|
187
|
+
'## Completed')
|
|
188
|
+
finish_task
|
|
189
|
+
in_active=0; in_backlog=0; in_completed=1
|
|
190
|
+
task_title=''
|
|
191
|
+
continue
|
|
192
|
+
;;
|
|
193
|
+
'## '*)
|
|
194
|
+
finish_task
|
|
195
|
+
in_active=0; in_backlog=0; in_completed=0
|
|
196
|
+
task_title=''
|
|
197
|
+
;;
|
|
198
|
+
esac
|
|
199
|
+
|
|
200
|
+
if [ "$in_backlog" -eq 1 ] && [[ "$line" =~ ^-[[:space:]]+(\[[[:space:]xX]\][[:space:]]+)? ]]; then
|
|
201
|
+
backlog_items=$((backlog_items + 1))
|
|
202
|
+
fi
|
|
203
|
+
if [ "$in_completed" -eq 1 ] && [[ "$line" =~ ^###[[:space:]]+ ]]; then
|
|
204
|
+
completed_items=$((completed_items + 1))
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if [ "$in_active" -eq 1 ]; then
|
|
208
|
+
if [ -n "$task_title" ]; then
|
|
209
|
+
task_line_count=$((task_line_count + 1))
|
|
210
|
+
fi
|
|
211
|
+
if [[ "$line" =~ ^###[[:space:]]+(.+) ]]; then
|
|
212
|
+
finish_task
|
|
213
|
+
active_count=$((active_count + 1))
|
|
214
|
+
task_title="${BASH_REMATCH[1]}"
|
|
215
|
+
task_status=''; task_level=''; task_current=''
|
|
216
|
+
task_has_intent=0; task_has_todo=0; task_has_changes=0; task_has_prereq=0; task_has_resume=0; task_has_blocked_by=0; task_has_close_summary=0
|
|
217
|
+
task_line_count=1
|
|
218
|
+
continue
|
|
219
|
+
fi
|
|
220
|
+
[[ "$line" =~ ^Status:[[:space:]]*(.+) ]] && [ -z "$task_status" ] && task_status="${BASH_REMATCH[1]}"
|
|
221
|
+
[[ "$line" =~ ^Level:[[:space:]]*([0-3]) ]] && task_level="${BASH_REMATCH[1]}"
|
|
222
|
+
[[ "$line" =~ ^Current[[:space:]]phase:[[:space:]]*(.+) ]] && task_current="${BASH_REMATCH[1]}"
|
|
223
|
+
[[ "$line" =~ ^Intent: ]] && task_has_intent=1
|
|
224
|
+
[[ "$line" =~ ^Current[[:space:]]todo: ]] && task_has_todo=1
|
|
225
|
+
[[ "$line" =~ ^Changes: ]] && task_has_changes=1
|
|
226
|
+
[[ "$line" =~ ^Prerequisites: ]] && task_has_prereq=1
|
|
227
|
+
[[ "$line" =~ ^Blocked[[:space:]]by: ]] && task_has_blocked_by=1
|
|
228
|
+
[[ "$line" =~ ^Resume[[:space:]]next: ]] && task_has_resume=1
|
|
229
|
+
[[ "$line" =~ ^Close[[:space:]]summary: ]] && task_has_close_summary=1
|
|
230
|
+
fi
|
|
231
|
+
done < "$LEDGER"
|
|
232
|
+
finish_task
|
|
233
|
+
|
|
234
|
+
if [ "$backlog_items" -gt 10 ]; then
|
|
235
|
+
say_warning 'Backlog / Future contains more than 10 items.'
|
|
236
|
+
fi
|
|
237
|
+
if [ "$active_count" -gt 1 ]; then
|
|
238
|
+
say_warning 'More than one Active task; include priority, blocker state, and Resume next if this is intentional.'
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
say_info "Active tasks: $active_count"
|
|
242
|
+
say_info "Backlog items: $backlog_items"
|
|
243
|
+
say_info "Completed tasks: $completed_items"
|
|
244
|
+
if ledger_mtime="$(stat -c %Y "$LEDGER" 2>/dev/null)"; then
|
|
245
|
+
say_info "Ledger modified: $(date -d "@$ledger_mtime" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '%s' "$ledger_mtime")"
|
|
246
|
+
fi
|
|
247
|
+
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
248
|
+
local commit_time
|
|
249
|
+
if commit_time="$(git -C "$ROOT" log -1 --format=%ct 2>/dev/null)" && [ -n "$commit_time" ]; then
|
|
250
|
+
say_info "Latest git commit: $(date -d "@$commit_time" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '%s' "$commit_time")"
|
|
251
|
+
if [ -n "${ledger_mtime:-}" ] && [ "$ledger_mtime" -lt "$commit_time" ]; then
|
|
252
|
+
say_warning 'Ledger modified time is older than latest git commit.'
|
|
253
|
+
fi
|
|
254
|
+
fi
|
|
255
|
+
fi
|
|
256
|
+
say_info "Hooks: $hook_status"
|
|
257
|
+
|
|
258
|
+
if [ "$error_count" -gt 0 ]; then
|
|
259
|
+
printf 'doctor finished with %d error(s), %d warning(s).\n' "$error_count" "$warning_count"
|
|
260
|
+
return 1
|
|
261
|
+
fi
|
|
262
|
+
printf 'doctor finished with 0 errors, %d warning(s).\n' "$warning_count"
|
|
263
|
+
return 0
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
cmd_list() {
|
|
267
|
+
if [ ! -f "$LEDGER" ]; then
|
|
268
|
+
printf 'No .claude/WORKFLOW.md found; no tasks to list.\n' >&2
|
|
269
|
+
return 0
|
|
270
|
+
fi
|
|
271
|
+
if [ ! -r "$LEDGER" ]; then
|
|
272
|
+
printf 'error: .claude/WORKFLOW.md exists but cannot be read.\n' >&2
|
|
273
|
+
return 1
|
|
274
|
+
fi
|
|
275
|
+
|
|
276
|
+
local in_active=0 in_backlog=0 in_completed=0 in_resume=0
|
|
277
|
+
local backlog_items=0 completed_items=0
|
|
278
|
+
local current_task='' status='' level='' current_phase='' resume_next=''
|
|
279
|
+
|
|
280
|
+
print_task() {
|
|
281
|
+
if [ -n "$current_task" ]; then
|
|
282
|
+
local meta=''
|
|
283
|
+
[ -n "$level" ] && meta="[Level $level]"
|
|
284
|
+
[ -n "$status" ] && meta="$meta $status"
|
|
285
|
+
printf -- '- %s %s\n' "$current_task" "$meta"
|
|
286
|
+
[ -n "$current_phase" ] && printf ' Current phase: %s\n' "$current_phase"
|
|
287
|
+
[ -n "$resume_next" ] && printf ' Resume next: %s\n' "$resume_next"
|
|
288
|
+
fi
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
printf 'Active:\n'
|
|
292
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
293
|
+
case "$line" in
|
|
294
|
+
'## Active') in_active=1; in_backlog=0; in_completed=0; in_resume=0; continue ;;
|
|
295
|
+
'## Backlog / Future') print_task; current_task=''; in_active=0; in_backlog=1; in_completed=0; in_resume=0; continue ;;
|
|
296
|
+
'## Completed') print_task; current_task=''; in_active=0; in_backlog=0; in_completed=1; in_resume=0; continue ;;
|
|
297
|
+
'## '*) print_task; current_task=''; in_active=0; in_backlog=0; in_completed=0; in_resume=0 ;;
|
|
298
|
+
esac
|
|
299
|
+
if [ "$in_active" -eq 1 ]; then
|
|
300
|
+
if [[ "$line" =~ ^###[[:space:]]+(.+) ]]; then
|
|
301
|
+
print_task
|
|
302
|
+
current_task="${BASH_REMATCH[1]}"; status=''; level=''; current_phase=''; resume_next=''; in_resume=0
|
|
303
|
+
elif [[ "$line" =~ ^Status:[[:space:]]*(.+) ]] && [ -z "$status" ]; then
|
|
304
|
+
status="${BASH_REMATCH[1]}"
|
|
305
|
+
in_resume=0
|
|
306
|
+
elif [[ "$line" =~ ^Level:[[:space:]]*([0-3]) ]]; then
|
|
307
|
+
level="${BASH_REMATCH[1]}"
|
|
308
|
+
in_resume=0
|
|
309
|
+
elif [[ "$line" =~ ^Current[[:space:]]phase:[[:space:]]*(.+) ]]; then
|
|
310
|
+
current_phase="${BASH_REMATCH[1]}"
|
|
311
|
+
in_resume=0
|
|
312
|
+
elif [[ "$line" =~ ^Resume[[:space:]]next: ]]; then
|
|
313
|
+
in_resume=1
|
|
314
|
+
elif [ "$in_resume" -eq 1 ] && [[ "$line" =~ ^-[[:space:]]+(.+) ]]; then
|
|
315
|
+
[ -z "$resume_next" ] && resume_next="${BASH_REMATCH[1]}"
|
|
316
|
+
elif [[ -n "$line" && ! "$line" =~ ^[[:space:]]*$ ]]; then
|
|
317
|
+
in_resume=0
|
|
318
|
+
fi
|
|
319
|
+
elif [ "$in_backlog" -eq 1 ] && [[ "$line" =~ ^-[[:space:]]+(\[[[:space:]xX]\][[:space:]]+)? ]]; then
|
|
320
|
+
backlog_items=$((backlog_items + 1))
|
|
321
|
+
elif [ "$in_completed" -eq 1 ] && [[ "$line" =~ ^###[[:space:]]+ ]]; then
|
|
322
|
+
completed_items=$((completed_items + 1))
|
|
323
|
+
fi
|
|
324
|
+
done < "$LEDGER"
|
|
325
|
+
print_task
|
|
326
|
+
printf '\nBacklog / Future:\n- %d items\n\nCompleted:\n- %d items\n' "$backlog_items" "$completed_items"
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
cmd_hooks_status() {
|
|
330
|
+
local hooks_json="$ROOT/.claude/hooks/hooks.json"
|
|
331
|
+
local hook_script="$ROOT/.claude/hooks/session-start"
|
|
332
|
+
printf 'hooks: %s\n' "$(hook_status_value)"
|
|
333
|
+
printf 'hooks.json: %s\n' "$hooks_json"
|
|
334
|
+
printf 'session-start: %s\n' "$hook_script"
|
|
335
|
+
return 0
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
cmd_hooks_install() {
|
|
339
|
+
local target_dir="$ROOT/.claude/hooks"
|
|
340
|
+
if ! mkdir -p "$target_dir" 2>/dev/null; then
|
|
341
|
+
printf 'error: cannot create .claude/hooks directory.\n' >&2
|
|
342
|
+
return 1
|
|
343
|
+
fi
|
|
344
|
+
if [ -e "$target_dir/hooks.json" ]; then
|
|
345
|
+
printf 'kept existing .claude/hooks/hooks.json\n'
|
|
346
|
+
else
|
|
347
|
+
cat > "$target_dir/hooks.json" <<'HOOKS_JSON'
|
|
348
|
+
{
|
|
349
|
+
"hooks": {
|
|
350
|
+
"SessionStart": [
|
|
351
|
+
{
|
|
352
|
+
"matcher": "",
|
|
353
|
+
"command": ".claude/hooks/session-start"
|
|
354
|
+
}
|
|
355
|
+
]
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
HOOKS_JSON
|
|
359
|
+
printf 'installed .claude/hooks/hooks.json\n'
|
|
360
|
+
fi
|
|
361
|
+
if [ -e "$target_dir/session-start" ]; then
|
|
362
|
+
printf 'kept existing .claude/hooks/session-start\n'
|
|
363
|
+
else
|
|
364
|
+
cat > "$target_dir/session-start" <<'HOOK_SCRIPT'
|
|
365
|
+
#!/usr/bin/env bash
|
|
366
|
+
set -u
|
|
367
|
+
|
|
368
|
+
ledger=".claude/WORKFLOW.md"
|
|
369
|
+
cli=".claude/bin/workflow-ledger"
|
|
370
|
+
|
|
371
|
+
if [ ! -f "$ledger" ]; then
|
|
372
|
+
exit 0
|
|
373
|
+
fi
|
|
374
|
+
|
|
375
|
+
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
|
|
376
|
+
cat <<'PLUGIN_JSON'
|
|
377
|
+
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Workflow Ledger detected.\n- Read .claude/WORKFLOW.md before resuming tracked work.\n- Check Active tasks, Current phase/current focus, Current todo, and Resume next.\n- Run workflow-ledger doctor if state may be stale."}}
|
|
378
|
+
PLUGIN_JSON
|
|
379
|
+
exit 0
|
|
380
|
+
fi
|
|
381
|
+
|
|
382
|
+
printf 'Workflow Ledger detected.\n'
|
|
383
|
+
printf -- '- Read .claude/WORKFLOW.md before resuming tracked work.\n'
|
|
384
|
+
printf -- '- Check Active tasks, Current phase/current focus, Current todo, and Resume next.\n'
|
|
385
|
+
|
|
386
|
+
if [ -x "$cli" ]; then
|
|
387
|
+
printf -- '- Run .claude/bin/workflow-ledger doctor if state may be stale.\n'
|
|
388
|
+
else
|
|
389
|
+
printf -- '- Run workflow-ledger doctor if the project CLI is available.\n'
|
|
390
|
+
fi
|
|
391
|
+
|
|
392
|
+
exit 0
|
|
393
|
+
HOOK_SCRIPT
|
|
394
|
+
chmod +x "$target_dir/session-start" || return 1
|
|
395
|
+
printf 'installed .claude/hooks/session-start\n'
|
|
396
|
+
fi
|
|
397
|
+
}
|
|
398
|
+
main() {
|
|
399
|
+
local cmd="${1:-help}"
|
|
400
|
+
case "$cmd" in
|
|
401
|
+
help|-h|--help) print_help; return 0 ;;
|
|
402
|
+
init) shift; cmd_init "$@" ;;
|
|
403
|
+
doctor) shift; cmd_doctor "$@" ;;
|
|
404
|
+
list) shift; cmd_list "$@" ;;
|
|
405
|
+
hooks)
|
|
406
|
+
local sub="${2:-status}"
|
|
407
|
+
case "$sub" in
|
|
408
|
+
status) cmd_hooks_status ;;
|
|
409
|
+
install) cmd_hooks_install ;;
|
|
410
|
+
*) printf 'error: unknown hooks command: %s\n' "$sub" >&2; return 1 ;;
|
|
411
|
+
esac
|
|
412
|
+
;;
|
|
413
|
+
*) printf 'error: unknown command: %s\n\n' "$cmd" >&2; print_help; return 1 ;;
|
|
414
|
+
esac
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
main "$@"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const repoRoot = path.resolve(__dirname, '..');
|
|
8
|
+
const targetRoot = path.resolve(process.env.WORKFLOW_LEDGER_ROOT || process.cwd());
|
|
9
|
+
|
|
10
|
+
const toolAliases = new Map([
|
|
11
|
+
['cc', 'claude-code'],
|
|
12
|
+
['claude', 'claude-code'],
|
|
13
|
+
['claude-code', 'claude-code'],
|
|
14
|
+
['codex', 'codex'],
|
|
15
|
+
['all', 'all'],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function printHelp() {
|
|
19
|
+
console.log(`workflow-ledger — lightweight workflow guardrails for AI coding agents
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
workflow-ledger setup [--tool claude-code|codex|all]
|
|
23
|
+
workflow-ledger init [--tool claude-code|codex|all] [--root PATH]
|
|
24
|
+
workflow-ledger help
|
|
25
|
+
workflow-ledger doctor
|
|
26
|
+
workflow-ledger list
|
|
27
|
+
workflow-ledger hooks status
|
|
28
|
+
workflow-ledger hooks install
|
|
29
|
+
|
|
30
|
+
setup installs global tool integrations. init creates project-local ledger files.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const args = { command: argv[0] || 'help', tool: 'claude-code', root: targetRoot };
|
|
35
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
if (arg === '--tool') {
|
|
38
|
+
args.tool = argv[i + 1] || '';
|
|
39
|
+
i += 1;
|
|
40
|
+
} else if (arg.startsWith('--tool=')) {
|
|
41
|
+
args.tool = arg.slice('--tool='.length);
|
|
42
|
+
} else if (arg === '--root') {
|
|
43
|
+
args.root = path.resolve(argv[i + 1] || '.');
|
|
44
|
+
i += 1;
|
|
45
|
+
} else if (arg.startsWith('--root=')) {
|
|
46
|
+
args.root = path.resolve(arg.slice('--root='.length));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
args.tool = toolAliases.get(args.tool) || args.tool;
|
|
50
|
+
return args;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ensureDir(dir) {
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dirExists(dir) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.statSync(dir).isDirectory();
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function copyFileIfMissing(src, dest, createdMessage, keptMessage, result) {
|
|
66
|
+
if (fs.existsSync(dest)) {
|
|
67
|
+
result.configured.push(keptMessage);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
ensureDir(path.dirname(dest));
|
|
71
|
+
fs.copyFileSync(src, dest);
|
|
72
|
+
result.configured.push(createdMessage);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function copyDir(src, dest) {
|
|
76
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
77
|
+
ensureDir(path.dirname(dest));
|
|
78
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function appendSnippet(marker, snippetPath, targetPath, updatedMessage, keptMessage, result) {
|
|
82
|
+
let current = '';
|
|
83
|
+
if (fs.existsSync(targetPath)) {
|
|
84
|
+
current = fs.readFileSync(targetPath, 'utf8');
|
|
85
|
+
} else {
|
|
86
|
+
ensureDir(path.dirname(targetPath));
|
|
87
|
+
}
|
|
88
|
+
if (current.includes(marker)) {
|
|
89
|
+
result.configured.push(keptMessage);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n\n' : current.length > 0 ? '\n' : '';
|
|
93
|
+
fs.appendFileSync(targetPath, `${prefix}${fs.readFileSync(snippetPath, 'utf8')}`);
|
|
94
|
+
result.configured.push(updatedMessage);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateTool(tool) {
|
|
98
|
+
if (!['claude-code', 'codex', 'all'].includes(tool)) {
|
|
99
|
+
console.error(`error: unknown tool '${tool}'. Expected claude-code, codex, or all.`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createResult() {
|
|
107
|
+
return { configured: [], skipped: [], errors: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function printResult(title, result) {
|
|
111
|
+
console.log(`\n${title}`);
|
|
112
|
+
if (result.configured.length > 0) {
|
|
113
|
+
console.log('Configured:');
|
|
114
|
+
for (const item of result.configured) console.log(` + ${item}`);
|
|
115
|
+
}
|
|
116
|
+
if (result.skipped.length > 0) {
|
|
117
|
+
console.log('Skipped:');
|
|
118
|
+
for (const item of result.skipped) console.log(` - ${item}`);
|
|
119
|
+
}
|
|
120
|
+
if (result.errors.length > 0) {
|
|
121
|
+
console.log('Errors:');
|
|
122
|
+
for (const item of result.errors) console.log(` ! ${item}`);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function setupClaudeCodeGlobal(result) {
|
|
128
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
129
|
+
if (!dirExists(claudeDir)) {
|
|
130
|
+
result.skipped.push('Claude Code (not installed)');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const skillsDir = path.join(claudeDir, 'skills');
|
|
134
|
+
const binDir = path.join(claudeDir, 'bin');
|
|
135
|
+
try {
|
|
136
|
+
copyDir(path.join(repoRoot, 'skills', 'workflow-ledger'), path.join(skillsDir, 'workflow-ledger'));
|
|
137
|
+
ensureDir(binDir);
|
|
138
|
+
fs.copyFileSync(path.join(repoRoot, 'bin', 'workflow-ledger'), path.join(binDir, 'workflow-ledger'));
|
|
139
|
+
fs.chmodSync(path.join(binDir, 'workflow-ledger'), 0o755);
|
|
140
|
+
result.configured.push('Claude Code skill → ~/.claude/skills/workflow-ledger');
|
|
141
|
+
result.configured.push('Claude Code local CLI → ~/.claude/bin/workflow-ledger');
|
|
142
|
+
} catch (error) {
|
|
143
|
+
result.errors.push(`Claude Code: ${error.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setupCodexGlobal(result) {
|
|
148
|
+
const codexDir = path.join(os.homedir(), '.codex');
|
|
149
|
+
if (!dirExists(codexDir)) {
|
|
150
|
+
result.skipped.push('Codex (not installed)');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const skillDir = path.join(os.homedir(), '.agents', 'skills', 'workflow-ledger');
|
|
154
|
+
try {
|
|
155
|
+
ensureDir(skillDir);
|
|
156
|
+
fs.copyFileSync(path.join(repoRoot, 'examples', 'codex-project', 'AGENTS.md.snippet'), path.join(skillDir, 'SKILL.md'));
|
|
157
|
+
result.configured.push('Codex skill → ~/.agents/skills/workflow-ledger');
|
|
158
|
+
} catch (error) {
|
|
159
|
+
result.errors.push(`Codex: ${error.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function setup(args) {
|
|
164
|
+
if (!validateTool(args.tool)) return;
|
|
165
|
+
const result = createResult();
|
|
166
|
+
if (args.tool === 'claude-code' || args.tool === 'all') setupClaudeCodeGlobal(result);
|
|
167
|
+
if (args.tool === 'codex' || args.tool === 'all') setupCodexGlobal(result);
|
|
168
|
+
printResult('Workflow Ledger Setup', result);
|
|
169
|
+
console.log('\nNext: run workflow-ledger init in a project.');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function initClaudeCodeProject(root, result) {
|
|
173
|
+
const claudeDir = path.join(root, '.claude');
|
|
174
|
+
ensureDir(claudeDir);
|
|
175
|
+
copyFileIfMissing(
|
|
176
|
+
path.join(repoRoot, 'skills', 'workflow-ledger', 'templates', 'WORKFLOW.md'),
|
|
177
|
+
path.join(claudeDir, 'WORKFLOW.md'),
|
|
178
|
+
'created .claude/WORKFLOW.md',
|
|
179
|
+
'kept existing .claude/WORKFLOW.md',
|
|
180
|
+
result
|
|
181
|
+
);
|
|
182
|
+
appendSnippet(
|
|
183
|
+
'## Workflow Ledger',
|
|
184
|
+
path.join(repoRoot, 'examples', 'claude-project', 'CLAUDE.md.snippet'),
|
|
185
|
+
path.join(root, 'CLAUDE.md'),
|
|
186
|
+
'updated CLAUDE.md',
|
|
187
|
+
'kept existing Workflow Ledger section in CLAUDE.md',
|
|
188
|
+
result
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function initCodexProject(root, result) {
|
|
193
|
+
const ledgerDir = path.join(root, '.workflow-ledger');
|
|
194
|
+
ensureDir(ledgerDir);
|
|
195
|
+
copyFileIfMissing(
|
|
196
|
+
path.join(repoRoot, 'templates', 'WORKFLOW.md'),
|
|
197
|
+
path.join(ledgerDir, 'WORKFLOW.md'),
|
|
198
|
+
'created .workflow-ledger/WORKFLOW.md',
|
|
199
|
+
'kept existing .workflow-ledger/WORKFLOW.md',
|
|
200
|
+
result
|
|
201
|
+
);
|
|
202
|
+
appendSnippet(
|
|
203
|
+
'# Workflow Ledger',
|
|
204
|
+
path.join(repoRoot, 'examples', 'codex-project', 'AGENTS.md.snippet'),
|
|
205
|
+
path.join(root, 'AGENTS.md'),
|
|
206
|
+
'updated AGENTS.md',
|
|
207
|
+
'kept existing Workflow Ledger section in AGENTS.md',
|
|
208
|
+
result
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function initProject(args) {
|
|
213
|
+
if (!validateTool(args.tool)) return;
|
|
214
|
+
const result = createResult();
|
|
215
|
+
ensureDir(args.root);
|
|
216
|
+
if (args.tool === 'claude-code' || args.tool === 'all') initClaudeCodeProject(args.root, result);
|
|
217
|
+
if (args.tool === 'codex' || args.tool === 'all') initCodexProject(args.root, result);
|
|
218
|
+
printResult('Workflow Ledger Init', result);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function delegateToBash(argv) {
|
|
222
|
+
const script = path.join(repoRoot, 'bin', 'workflow-ledger');
|
|
223
|
+
const result = spawnSync(script, argv, {
|
|
224
|
+
stdio: 'inherit',
|
|
225
|
+
env: { ...process.env, WORKFLOW_LEDGER_ROOT: process.env.WORKFLOW_LEDGER_ROOT || targetRoot },
|
|
226
|
+
});
|
|
227
|
+
process.exitCode = result.status ?? 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const args = parseArgs(process.argv.slice(2));
|
|
231
|
+
if (args.command === 'help' || args.command === '-h' || args.command === '--help') {
|
|
232
|
+
printHelp();
|
|
233
|
+
} else if (args.command === 'setup') {
|
|
234
|
+
setup(args);
|
|
235
|
+
} else if (args.command === 'init') {
|
|
236
|
+
initProject(args);
|
|
237
|
+
} else {
|
|
238
|
+
delegateToBash(process.argv.slice(2));
|
|
239
|
+
}
|