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.
@@ -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
+ }