workflow-ledger 0.3.5 → 0.3.8
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -12
- package/README.zh-CN.md +15 -12
- package/bin/workflow-ledger.js +548 -31
- package/docs/cli.md +18 -13
- package/docs/design.md +1 -1
- package/docs/design.zh-CN.md +1 -1
- package/docs/usage.md +12 -9
- package/docs/usage.zh-CN.md +9 -6
- package/examples/claude-project/CLAUDE.md.snippet +3 -2
- package/examples/claude-project/CLAUDE.zh-CN.md.snippet +19 -0
- package/examples/codex-project/AGENTS.md.snippet +1 -0
- package/examples/codex-project/AGENTS.zh-CN.md.snippet +12 -0
- package/install.sh +7 -34
- package/package.json +1 -1
- package/skills/workflow-ledger/SKILL.md +11 -5
- package/skills/workflow-ledger/templates/WORKFLOW.zh-CN.md +50 -0
- package/templates/WORKFLOW.zh-CN.md +50 -0
- package/bin/workflow-ledger +0 -417
package/bin/workflow-ledger.js
CHANGED
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const readline = require('readline/promises');
|
|
6
|
+
const { stdin: input, stdout: output } = require('process');
|
|
5
7
|
const { spawnSync } = require('child_process');
|
|
6
8
|
|
|
7
9
|
const repoRoot = path.resolve(__dirname, '..');
|
|
8
|
-
|
|
10
|
+
|
|
11
|
+
function defaultTargetRoot() {
|
|
12
|
+
if (process.env.WORKFLOW_LEDGER_ROOT) return path.resolve(process.env.WORKFLOW_LEDGER_ROOT);
|
|
13
|
+
if (path.basename(__dirname) === 'bin' && path.basename(path.dirname(__dirname)) === '.claude') {
|
|
14
|
+
return path.resolve(__dirname, '..', '..');
|
|
15
|
+
}
|
|
16
|
+
return process.cwd();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const targetRoot = defaultTargetRoot();
|
|
9
20
|
|
|
10
21
|
const toolAliases = new Map([
|
|
11
22
|
['cc', 'claude-code'],
|
|
@@ -15,12 +26,23 @@ const toolAliases = new Map([
|
|
|
15
26
|
['all', 'all'],
|
|
16
27
|
]);
|
|
17
28
|
|
|
29
|
+
const languageAliases = new Map([
|
|
30
|
+
['en', 'en'],
|
|
31
|
+
['english', 'en'],
|
|
32
|
+
['zh', 'zh-CN'],
|
|
33
|
+
['zh-cn', 'zh-CN'],
|
|
34
|
+
['zh_CN', 'zh-CN'],
|
|
35
|
+
['cn', 'zh-CN'],
|
|
36
|
+
['chinese', 'zh-CN'],
|
|
37
|
+
['中文', 'zh-CN'],
|
|
38
|
+
]);
|
|
39
|
+
|
|
18
40
|
function printHelp() {
|
|
19
41
|
console.log(`workflow-ledger — lightweight workflow guardrails for AI coding agents
|
|
20
42
|
|
|
21
43
|
Usage:
|
|
22
44
|
workflow-ledger setup [--tool claude-code|codex|all]
|
|
23
|
-
workflow-ledger init [--tool claude-code|codex|all] [--root PATH]
|
|
45
|
+
workflow-ledger init [--tool claude-code|codex|all] [--lang en|zh-CN] [--root PATH]
|
|
24
46
|
workflow-ledger help
|
|
25
47
|
workflow-ledger doctor
|
|
26
48
|
workflow-ledger list
|
|
@@ -31,14 +53,26 @@ setup installs global tool integrations. init creates project-local ledger files
|
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
function parseArgs(argv) {
|
|
34
|
-
const args = { command: argv[0] || 'help', tool: 'claude-code', root: targetRoot };
|
|
56
|
+
const args = { command: argv[0] || 'help', tool: 'claude-code', language: '', root: targetRoot, interactiveLanguage: argv[0] === 'init' };
|
|
35
57
|
for (let i = 1; i < argv.length; i += 1) {
|
|
36
58
|
const arg = argv[i];
|
|
37
59
|
if (arg === '--tool') {
|
|
38
60
|
args.tool = argv[i + 1] || '';
|
|
61
|
+
args.interactiveLanguage = false;
|
|
39
62
|
i += 1;
|
|
40
63
|
} else if (arg.startsWith('--tool=')) {
|
|
41
64
|
args.tool = arg.slice('--tool='.length);
|
|
65
|
+
args.interactiveLanguage = false;
|
|
66
|
+
} else if (arg === '--lang' || arg === '--language') {
|
|
67
|
+
args.language = argv[i + 1] || '';
|
|
68
|
+
args.interactiveLanguage = false;
|
|
69
|
+
i += 1;
|
|
70
|
+
} else if (arg.startsWith('--lang=')) {
|
|
71
|
+
args.language = arg.slice('--lang='.length);
|
|
72
|
+
args.interactiveLanguage = false;
|
|
73
|
+
} else if (arg.startsWith('--language=')) {
|
|
74
|
+
args.language = arg.slice('--language='.length);
|
|
75
|
+
args.interactiveLanguage = false;
|
|
42
76
|
} else if (arg === '--root') {
|
|
43
77
|
args.root = path.resolve(argv[i + 1] || '.');
|
|
44
78
|
i += 1;
|
|
@@ -47,6 +81,7 @@ function parseArgs(argv) {
|
|
|
47
81
|
}
|
|
48
82
|
}
|
|
49
83
|
args.tool = toolAliases.get(args.tool) || args.tool;
|
|
84
|
+
args.language = args.language ? languageAliases.get(args.language) || args.language : 'en';
|
|
50
85
|
return args;
|
|
51
86
|
}
|
|
52
87
|
|
|
@@ -78,6 +113,12 @@ function copyDir(src, dest) {
|
|
|
78
113
|
fs.cpSync(src, dest, { recursive: true });
|
|
79
114
|
}
|
|
80
115
|
|
|
116
|
+
function copyCli(dest) {
|
|
117
|
+
ensureDir(path.dirname(dest));
|
|
118
|
+
fs.copyFileSync(__filename, dest);
|
|
119
|
+
fs.chmodSync(dest, 0o755);
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
function appendSnippet(marker, snippetPath, targetPath, updatedMessage, keptMessage, result) {
|
|
82
123
|
let current = '';
|
|
83
124
|
if (fs.existsSync(targetPath)) {
|
|
@@ -103,6 +144,32 @@ function validateTool(tool) {
|
|
|
103
144
|
return true;
|
|
104
145
|
}
|
|
105
146
|
|
|
147
|
+
function validateLanguage(language) {
|
|
148
|
+
if (!['en', 'zh-CN'].includes(language)) {
|
|
149
|
+
console.error(`error: unknown language '${language}'. Expected en or zh-CN.`);
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function chooseLanguage() {
|
|
157
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return 'en';
|
|
158
|
+
const rl = readline.createInterface({ input, output });
|
|
159
|
+
try {
|
|
160
|
+
const answer = await rl.question('Choose language / 选择语言 [1] English [2] 简体中文: ');
|
|
161
|
+
const normalized = answer.trim().toLowerCase();
|
|
162
|
+
if (['2', 'zh', 'zh-cn', 'cn', '中文'].includes(normalized)) return 'zh-CN';
|
|
163
|
+
return 'en';
|
|
164
|
+
} finally {
|
|
165
|
+
rl.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function localizedPath(...segments) {
|
|
170
|
+
return path.join(repoRoot, ...segments);
|
|
171
|
+
}
|
|
172
|
+
|
|
106
173
|
function createResult() {
|
|
107
174
|
return { configured: [], skipped: [], errors: [] };
|
|
108
175
|
}
|
|
@@ -134,9 +201,7 @@ function setupClaudeCodeGlobal(result) {
|
|
|
134
201
|
const binDir = path.join(claudeDir, 'bin');
|
|
135
202
|
try {
|
|
136
203
|
copyDir(path.join(repoRoot, 'skills', 'workflow-ledger'), path.join(skillsDir, 'workflow-ledger'));
|
|
137
|
-
|
|
138
|
-
fs.copyFileSync(path.join(repoRoot, 'bin', 'workflow-ledger'), path.join(binDir, 'workflow-ledger'));
|
|
139
|
-
fs.chmodSync(path.join(binDir, 'workflow-ledger'), 0o755);
|
|
204
|
+
copyCli(path.join(binDir, 'workflow-ledger'));
|
|
140
205
|
result.configured.push('Claude Code skill → ~/.claude/skills/workflow-ledger');
|
|
141
206
|
result.configured.push('Claude Code local CLI → ~/.claude/bin/workflow-ledger');
|
|
142
207
|
} catch (error) {
|
|
@@ -169,11 +234,19 @@ function setup(args) {
|
|
|
169
234
|
console.log('\nNext: run workflow-ledger init in a project.');
|
|
170
235
|
}
|
|
171
236
|
|
|
172
|
-
function
|
|
237
|
+
function templateFile(language, englishPath, chinesePath) {
|
|
238
|
+
return language === 'zh-CN' ? localizedPath(...chinesePath) : localizedPath(...englishPath);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function initClaudeCodeProject(root, language, result) {
|
|
173
242
|
const claudeDir = path.join(root, '.claude');
|
|
174
243
|
ensureDir(claudeDir);
|
|
175
244
|
copyFileIfMissing(
|
|
176
|
-
|
|
245
|
+
templateFile(
|
|
246
|
+
language,
|
|
247
|
+
['skills', 'workflow-ledger', 'templates', 'WORKFLOW.md'],
|
|
248
|
+
['skills', 'workflow-ledger', 'templates', 'WORKFLOW.zh-CN.md']
|
|
249
|
+
),
|
|
177
250
|
path.join(claudeDir, 'WORKFLOW.md'),
|
|
178
251
|
'created .claude/WORKFLOW.md',
|
|
179
252
|
'kept existing .claude/WORKFLOW.md',
|
|
@@ -181,7 +254,11 @@ function initClaudeCodeProject(root, result) {
|
|
|
181
254
|
);
|
|
182
255
|
appendSnippet(
|
|
183
256
|
'## Workflow Ledger',
|
|
184
|
-
|
|
257
|
+
templateFile(
|
|
258
|
+
language,
|
|
259
|
+
['examples', 'claude-project', 'CLAUDE.md.snippet'],
|
|
260
|
+
['examples', 'claude-project', 'CLAUDE.zh-CN.md.snippet']
|
|
261
|
+
),
|
|
185
262
|
path.join(root, 'CLAUDE.md'),
|
|
186
263
|
'updated CLAUDE.md',
|
|
187
264
|
'kept existing Workflow Ledger section in CLAUDE.md',
|
|
@@ -189,11 +266,15 @@ function initClaudeCodeProject(root, result) {
|
|
|
189
266
|
);
|
|
190
267
|
}
|
|
191
268
|
|
|
192
|
-
function initCodexProject(root, result) {
|
|
269
|
+
function initCodexProject(root, language, result) {
|
|
193
270
|
const ledgerDir = path.join(root, '.workflow-ledger');
|
|
194
271
|
ensureDir(ledgerDir);
|
|
195
272
|
copyFileIfMissing(
|
|
196
|
-
|
|
273
|
+
templateFile(
|
|
274
|
+
language,
|
|
275
|
+
['templates', 'WORKFLOW.md'],
|
|
276
|
+
['templates', 'WORKFLOW.zh-CN.md']
|
|
277
|
+
),
|
|
197
278
|
path.join(ledgerDir, 'WORKFLOW.md'),
|
|
198
279
|
'created .workflow-ledger/WORKFLOW.md',
|
|
199
280
|
'kept existing .workflow-ledger/WORKFLOW.md',
|
|
@@ -201,7 +282,11 @@ function initCodexProject(root, result) {
|
|
|
201
282
|
);
|
|
202
283
|
appendSnippet(
|
|
203
284
|
'# Workflow Ledger',
|
|
204
|
-
|
|
285
|
+
templateFile(
|
|
286
|
+
language,
|
|
287
|
+
['examples', 'codex-project', 'AGENTS.md.snippet'],
|
|
288
|
+
['examples', 'codex-project', 'AGENTS.zh-CN.md.snippet']
|
|
289
|
+
),
|
|
205
290
|
path.join(root, 'AGENTS.md'),
|
|
206
291
|
'updated AGENTS.md',
|
|
207
292
|
'kept existing Workflow Ledger section in AGENTS.md',
|
|
@@ -209,31 +294,463 @@ function initCodexProject(root, result) {
|
|
|
209
294
|
);
|
|
210
295
|
}
|
|
211
296
|
|
|
212
|
-
function initProject(args) {
|
|
297
|
+
async function initProject(args) {
|
|
213
298
|
if (!validateTool(args.tool)) return;
|
|
299
|
+
if (!validateLanguage(args.language)) return;
|
|
300
|
+
const language = args.interactiveLanguage ? await chooseLanguage() : args.language;
|
|
214
301
|
const result = createResult();
|
|
215
302
|
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);
|
|
303
|
+
if (args.tool === 'claude-code' || args.tool === 'all') initClaudeCodeProject(args.root, language, result);
|
|
304
|
+
if (args.tool === 'codex' || args.tool === 'all') initCodexProject(args.root, language, result);
|
|
218
305
|
printResult('Workflow Ledger Init', result);
|
|
219
306
|
}
|
|
220
307
|
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
308
|
+
function ledgerPath(root = targetRoot) {
|
|
309
|
+
return path.join(root, '.claude', 'WORKFLOW.md');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function hooksJsonPath(root = targetRoot) {
|
|
313
|
+
return path.join(root, '.claude', 'hooks', 'hooks.json');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function hookScriptPath(root = targetRoot) {
|
|
317
|
+
return path.join(root, '.claude', 'hooks', 'session-start');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isExecutable(filePath) {
|
|
321
|
+
try {
|
|
322
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
323
|
+
return true;
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function hookStatusValue(root = targetRoot) {
|
|
330
|
+
const hooksJson = hooksJsonPath(root);
|
|
331
|
+
const hookScript = hookScriptPath(root);
|
|
332
|
+
if (!fs.existsSync(hooksJson) || !fs.existsSync(hookScript)) return 'not installed';
|
|
333
|
+
const hooksText = fs.readFileSync(hooksJson, 'utf8');
|
|
334
|
+
if (hooksText.includes('SessionStart') && hooksText.includes('.claude/hooks/session-start') && isExecutable(hookScript)) {
|
|
335
|
+
return 'installed';
|
|
336
|
+
}
|
|
337
|
+
return 'incomplete';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function hasSection(lines, name) {
|
|
341
|
+
return lines.some((line) => new RegExp(`^##\\s+${name}\\s*$`).test(line));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function formatLocalTimestamp(epochSeconds) {
|
|
345
|
+
const date = new Date(epochSeconds * 1000);
|
|
346
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
347
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function cmdDoctor() {
|
|
351
|
+
const ledger = ledgerPath();
|
|
352
|
+
let errorCount = 0;
|
|
353
|
+
let warningCount = 0;
|
|
354
|
+
const out = [];
|
|
355
|
+
|
|
356
|
+
const sayError = (message) => {
|
|
357
|
+
errorCount += 1;
|
|
358
|
+
out.push(`ERROR: ${message}`);
|
|
359
|
+
};
|
|
360
|
+
const sayWarning = (message) => {
|
|
361
|
+
warningCount += 1;
|
|
362
|
+
out.push(`WARNING: ${message}`);
|
|
363
|
+
};
|
|
364
|
+
const sayInfo = (message) => out.push(`INFO: ${message}`);
|
|
365
|
+
|
|
366
|
+
if (!fs.existsSync(ledger)) {
|
|
367
|
+
sayError('.claude/WORKFLOW.md is missing.');
|
|
368
|
+
console.log(out.join('\n'));
|
|
369
|
+
process.exitCode = 1;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let text = '';
|
|
374
|
+
try {
|
|
375
|
+
text = fs.readFileSync(ledger, 'utf8');
|
|
376
|
+
} catch {
|
|
377
|
+
sayError('.claude/WORKFLOW.md exists but cannot be read.');
|
|
378
|
+
console.log(out.join('\n'));
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const lines = text.split(/\r?\n/);
|
|
384
|
+
if (!hasSection(lines, 'Active')) sayError('Missing ## Active section.');
|
|
385
|
+
if (!hasSection(lines, 'Backlog / Future')) sayError('Missing ## Backlog / Future section.');
|
|
386
|
+
if (!hasSection(lines, 'Completed')) sayError('Missing ## Completed section.');
|
|
387
|
+
|
|
388
|
+
let activeCount = 0;
|
|
389
|
+
let backlogItems = 0;
|
|
390
|
+
let completedItems = 0;
|
|
391
|
+
let inActive = false;
|
|
392
|
+
let inBacklog = false;
|
|
393
|
+
let inCompleted = false;
|
|
394
|
+
let task = null;
|
|
395
|
+
|
|
396
|
+
const finishTask = () => {
|
|
397
|
+
if (!task) return;
|
|
398
|
+
if (task.status === 'In Progress') {
|
|
399
|
+
if (!task.currentPhase) sayError(`In Progress task '${task.title}' lacks Current phase.`);
|
|
400
|
+
if (!task.hasIntent) sayError(`In Progress task '${task.title}' lacks Intent.`);
|
|
401
|
+
if (!task.hasTodo) sayError(`In Progress task '${task.title}' lacks Current todo.`);
|
|
402
|
+
if (!task.hasResume) sayError(`In Progress task '${task.title}' lacks Resume next.`);
|
|
403
|
+
if (task.level === '2' || task.level === '3') {
|
|
404
|
+
if (!task.hasChanges) sayWarning(`Level ${task.level} task '${task.title}' lacks Changes.`);
|
|
405
|
+
if (!task.hasPrerequisites) sayWarning(`Level ${task.level} task '${task.title}' lacks Prerequisites.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (task.status === 'Blocked') {
|
|
409
|
+
if (!task.hasBlockedBy) sayError(`Blocked task '${task.title}' lacks Blocked by.`);
|
|
410
|
+
if (!task.hasResume) sayError(`Blocked task '${task.title}' lacks Resume next.`);
|
|
411
|
+
}
|
|
412
|
+
if (task.status === 'Done' || task.status === 'Completed') {
|
|
413
|
+
if (!task.hasCloseSummary) sayWarning(`Completed task '${task.title}' is still under Active and lacks Close summary. Move it to ## Completed when closing.`);
|
|
414
|
+
}
|
|
415
|
+
if (task.lineCount > 80) sayWarning(`Task '${task.title}' has more than 80 lines.`);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
for (const line of lines) {
|
|
419
|
+
if (line === '## Active') {
|
|
420
|
+
finishTask();
|
|
421
|
+
inActive = true;
|
|
422
|
+
inBacklog = false;
|
|
423
|
+
inCompleted = false;
|
|
424
|
+
task = null;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (line === '## Backlog / Future') {
|
|
428
|
+
finishTask();
|
|
429
|
+
inActive = false;
|
|
430
|
+
inBacklog = true;
|
|
431
|
+
inCompleted = false;
|
|
432
|
+
task = null;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (line === '## Completed') {
|
|
436
|
+
finishTask();
|
|
437
|
+
inActive = false;
|
|
438
|
+
inBacklog = false;
|
|
439
|
+
inCompleted = true;
|
|
440
|
+
task = null;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (line.startsWith('## ')) {
|
|
444
|
+
finishTask();
|
|
445
|
+
inActive = false;
|
|
446
|
+
inBacklog = false;
|
|
447
|
+
inCompleted = false;
|
|
448
|
+
task = null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (inBacklog && /^-\s+(\[[ xX]\]\s+)?/.test(line)) backlogItems += 1;
|
|
452
|
+
if (inCompleted && /^###\s+/.test(line)) completedItems += 1;
|
|
453
|
+
|
|
454
|
+
if (!inActive) continue;
|
|
455
|
+
if (task) task.lineCount += 1;
|
|
456
|
+
const titleMatch = line.match(/^###\s+(.+)/);
|
|
457
|
+
if (titleMatch) {
|
|
458
|
+
finishTask();
|
|
459
|
+
activeCount += 1;
|
|
460
|
+
task = {
|
|
461
|
+
title: titleMatch[1],
|
|
462
|
+
status: '',
|
|
463
|
+
level: '',
|
|
464
|
+
currentPhase: '',
|
|
465
|
+
hasIntent: false,
|
|
466
|
+
hasTodo: false,
|
|
467
|
+
hasChanges: false,
|
|
468
|
+
hasPrerequisites: false,
|
|
469
|
+
hasResume: false,
|
|
470
|
+
hasBlockedBy: false,
|
|
471
|
+
hasCloseSummary: false,
|
|
472
|
+
lineCount: 1,
|
|
473
|
+
};
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!task) continue;
|
|
477
|
+
const statusMatch = line.match(/^Status:\s*(.+)/);
|
|
478
|
+
if (statusMatch && !task.status) task.status = statusMatch[1];
|
|
479
|
+
const levelMatch = line.match(/^Level:\s*([0-3])/);
|
|
480
|
+
if (levelMatch) task.level = levelMatch[1];
|
|
481
|
+
const currentMatch = line.match(/^Current\s+phase:\s*(.+)/);
|
|
482
|
+
if (currentMatch) task.currentPhase = currentMatch[1];
|
|
483
|
+
if (/^Intent:/.test(line)) task.hasIntent = true;
|
|
484
|
+
if (/^Current\s+todo:/.test(line)) task.hasTodo = true;
|
|
485
|
+
if (/^Changes:/.test(line)) task.hasChanges = true;
|
|
486
|
+
if (/^Prerequisites:/.test(line)) task.hasPrerequisites = true;
|
|
487
|
+
if (/^Blocked\s+by:/.test(line)) task.hasBlockedBy = true;
|
|
488
|
+
if (/^Resume\s+next:/.test(line)) task.hasResume = true;
|
|
489
|
+
if (/^Close\s+summary:/.test(line)) task.hasCloseSummary = true;
|
|
490
|
+
}
|
|
491
|
+
finishTask();
|
|
492
|
+
|
|
493
|
+
if (backlogItems > 10) sayWarning('Backlog / Future contains more than 10 items.');
|
|
494
|
+
if (activeCount > 1) sayWarning('More than one Active task; include priority, blocker state, and Resume next if this is intentional.');
|
|
495
|
+
|
|
496
|
+
sayInfo(`Active tasks: ${activeCount}`);
|
|
497
|
+
sayInfo(`Backlog items: ${backlogItems}`);
|
|
498
|
+
sayInfo(`Completed tasks: ${completedItems}`);
|
|
499
|
+
|
|
500
|
+
let ledgerMtime = 0;
|
|
501
|
+
try {
|
|
502
|
+
ledgerMtime = Math.floor(fs.statSync(ledger).mtimeMs / 1000);
|
|
503
|
+
sayInfo(`Ledger modified: ${formatLocalTimestamp(ledgerMtime)}`);
|
|
504
|
+
} catch {
|
|
505
|
+
ledgerMtime = 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const inGit = spawnSync('git', ['-C', targetRoot, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
|
|
509
|
+
if (inGit.status === 0) {
|
|
510
|
+
const latest = spawnSync('git', ['-C', targetRoot, 'log', '-1', '--format=%ct'], { encoding: 'utf8' });
|
|
511
|
+
const commitTime = Number(latest.stdout.trim());
|
|
512
|
+
if (latest.status === 0 && Number.isFinite(commitTime) && commitTime > 0) {
|
|
513
|
+
sayInfo(`Latest git commit: ${formatLocalTimestamp(commitTime)}`);
|
|
514
|
+
if (ledgerMtime > 0 && ledgerMtime < commitTime) sayWarning('Ledger modified time is older than latest git commit.');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
sayInfo(`Hooks: ${hookStatusValue()}`);
|
|
519
|
+
if (errorCount > 0) {
|
|
520
|
+
out.push(`doctor finished with ${errorCount} error(s), ${warningCount} warning(s).`);
|
|
521
|
+
process.exitCode = 1;
|
|
522
|
+
} else {
|
|
523
|
+
out.push(`doctor finished with 0 errors, ${warningCount} warning(s).`);
|
|
524
|
+
}
|
|
525
|
+
console.log(out.join('\n'));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function cmdList() {
|
|
529
|
+
const ledger = ledgerPath();
|
|
530
|
+
if (!fs.existsSync(ledger)) {
|
|
531
|
+
console.error('No .claude/WORKFLOW.md found; no tasks to list.');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let text = '';
|
|
536
|
+
try {
|
|
537
|
+
text = fs.readFileSync(ledger, 'utf8');
|
|
538
|
+
} catch {
|
|
539
|
+
console.error('error: .claude/WORKFLOW.md exists but cannot be read.');
|
|
540
|
+
process.exitCode = 1;
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const lines = text.split(/\r?\n/);
|
|
545
|
+
const out = ['Active:'];
|
|
546
|
+
let inActive = false;
|
|
547
|
+
let inBacklog = false;
|
|
548
|
+
let inCompleted = false;
|
|
549
|
+
let inResume = false;
|
|
550
|
+
let backlogItems = 0;
|
|
551
|
+
let completedItems = 0;
|
|
552
|
+
let currentTask = '';
|
|
553
|
+
let status = '';
|
|
554
|
+
let level = '';
|
|
555
|
+
let currentPhase = '';
|
|
556
|
+
let resumeNext = '';
|
|
557
|
+
|
|
558
|
+
const printTask = () => {
|
|
559
|
+
if (!currentTask) return;
|
|
560
|
+
let meta = '';
|
|
561
|
+
if (level) meta = `[Level ${level}]`;
|
|
562
|
+
if (status) meta = `${meta} ${status}`;
|
|
563
|
+
out.push(`- ${currentTask} ${meta}`);
|
|
564
|
+
if (currentPhase) out.push(` Current phase: ${currentPhase}`);
|
|
565
|
+
if (resumeNext) out.push(` Resume next: ${resumeNext}`);
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
for (const line of lines) {
|
|
569
|
+
if (line === '## Active') {
|
|
570
|
+
inActive = true;
|
|
571
|
+
inBacklog = false;
|
|
572
|
+
inCompleted = false;
|
|
573
|
+
inResume = false;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (line === '## Backlog / Future') {
|
|
577
|
+
printTask();
|
|
578
|
+
currentTask = '';
|
|
579
|
+
inActive = false;
|
|
580
|
+
inBacklog = true;
|
|
581
|
+
inCompleted = false;
|
|
582
|
+
inResume = false;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (line === '## Completed') {
|
|
586
|
+
printTask();
|
|
587
|
+
currentTask = '';
|
|
588
|
+
inActive = false;
|
|
589
|
+
inBacklog = false;
|
|
590
|
+
inCompleted = true;
|
|
591
|
+
inResume = false;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (line.startsWith('## ')) {
|
|
595
|
+
printTask();
|
|
596
|
+
currentTask = '';
|
|
597
|
+
inActive = false;
|
|
598
|
+
inBacklog = false;
|
|
599
|
+
inCompleted = false;
|
|
600
|
+
inResume = false;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (inActive) {
|
|
604
|
+
const titleMatch = line.match(/^###\s+(.+)/);
|
|
605
|
+
if (titleMatch) {
|
|
606
|
+
printTask();
|
|
607
|
+
currentTask = titleMatch[1];
|
|
608
|
+
status = '';
|
|
609
|
+
level = '';
|
|
610
|
+
currentPhase = '';
|
|
611
|
+
resumeNext = '';
|
|
612
|
+
inResume = false;
|
|
613
|
+
} else {
|
|
614
|
+
const statusMatch = line.match(/^Status:\s*(.+)/);
|
|
615
|
+
const levelMatch = line.match(/^Level:\s*([0-3])/);
|
|
616
|
+
const currentMatch = line.match(/^Current\s+phase:\s*(.+)/);
|
|
617
|
+
if (statusMatch && !status) {
|
|
618
|
+
status = statusMatch[1];
|
|
619
|
+
inResume = false;
|
|
620
|
+
} else if (levelMatch) {
|
|
621
|
+
level = levelMatch[1];
|
|
622
|
+
inResume = false;
|
|
623
|
+
} else if (currentMatch) {
|
|
624
|
+
currentPhase = currentMatch[1];
|
|
625
|
+
inResume = false;
|
|
626
|
+
} else if (/^Resume\s+next:/.test(line)) {
|
|
627
|
+
inResume = true;
|
|
628
|
+
} else if (inResume && /^-\s+(.+)/.test(line)) {
|
|
629
|
+
if (!resumeNext) resumeNext = line.replace(/^-\s+/, '');
|
|
630
|
+
} else if (line.trim()) {
|
|
631
|
+
inResume = false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else if (inBacklog && /^-\s+(\[[ xX]\]\s+)?/.test(line)) {
|
|
635
|
+
backlogItems += 1;
|
|
636
|
+
} else if (inCompleted && /^###\s+/.test(line)) {
|
|
637
|
+
completedItems += 1;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
printTask();
|
|
641
|
+
out.push('', 'Backlog / Future:', `- ${backlogItems} items`, '', 'Completed:', `- ${completedItems} items`);
|
|
642
|
+
console.log(out.join('\n'));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function cmdHooksStatus() {
|
|
646
|
+
console.log(`hooks: ${hookStatusValue()}`);
|
|
647
|
+
console.log(`hooks.json: ${hooksJsonPath()}`);
|
|
648
|
+
console.log(`session-start: ${hookScriptPath()}`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const DEFAULT_HOOKS_JSON = `{
|
|
652
|
+
"hooks": {
|
|
653
|
+
"SessionStart": [
|
|
654
|
+
{
|
|
655
|
+
"matcher": "",
|
|
656
|
+
"command": ".claude/hooks/session-start"
|
|
657
|
+
}
|
|
658
|
+
]
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
`;
|
|
662
|
+
|
|
663
|
+
const DEFAULT_SESSION_START_HOOK = `#!/usr/bin/env bash
|
|
664
|
+
set -u
|
|
665
|
+
|
|
666
|
+
ledger=".claude/WORKFLOW.md"
|
|
667
|
+
cli=".claude/bin/workflow-ledger"
|
|
668
|
+
|
|
669
|
+
if [ ! -f "$ledger" ]; then
|
|
670
|
+
exit 0
|
|
671
|
+
fi
|
|
672
|
+
|
|
673
|
+
if [ -n "\${CLAUDE_PLUGIN_ROOT:-}" ]; then
|
|
674
|
+
cat <<'PLUGIN_JSON'
|
|
675
|
+
{"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."}}
|
|
676
|
+
PLUGIN_JSON
|
|
677
|
+
exit 0
|
|
678
|
+
fi
|
|
679
|
+
|
|
680
|
+
printf 'Workflow Ledger detected.\\n'
|
|
681
|
+
printf -- '- Read .claude/WORKFLOW.md before resuming tracked work.\\n'
|
|
682
|
+
printf -- '- Check Active tasks, Current phase/current focus, Current todo, and Resume next.\\n'
|
|
683
|
+
|
|
684
|
+
if [ -x "$cli" ]; then
|
|
685
|
+
printf -- '- Run .claude/bin/workflow-ledger doctor if state may be stale.\\n'
|
|
686
|
+
else
|
|
687
|
+
printf -- '- Run workflow-ledger doctor if the project CLI is available.\\n'
|
|
688
|
+
fi
|
|
689
|
+
|
|
690
|
+
exit 0
|
|
691
|
+
`;
|
|
692
|
+
|
|
693
|
+
function cmdHooksInstall() {
|
|
694
|
+
const targetDir = path.join(targetRoot, '.claude', 'hooks');
|
|
695
|
+
try {
|
|
696
|
+
ensureDir(targetDir);
|
|
697
|
+
} catch {
|
|
698
|
+
console.error('error: cannot create .claude/hooks directory.');
|
|
699
|
+
process.exitCode = 1;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const hooksJson = hooksJsonPath();
|
|
704
|
+
const hookScript = hookScriptPath();
|
|
705
|
+
if (fs.existsSync(hooksJson)) {
|
|
706
|
+
console.log('kept existing .claude/hooks/hooks.json');
|
|
707
|
+
} else {
|
|
708
|
+
fs.writeFileSync(hooksJson, DEFAULT_HOOKS_JSON);
|
|
709
|
+
console.log('installed .claude/hooks/hooks.json');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (fs.existsSync(hookScript)) {
|
|
713
|
+
console.log('kept existing .claude/hooks/session-start');
|
|
714
|
+
} else {
|
|
715
|
+
fs.writeFileSync(hookScript, DEFAULT_SESSION_START_HOOK, { mode: 0o755 });
|
|
716
|
+
fs.chmodSync(hookScript, 0o755);
|
|
717
|
+
console.log('installed .claude/hooks/session-start');
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function runCommand(argv) {
|
|
722
|
+
const command = argv[0] || 'help';
|
|
723
|
+
if (command === 'help' || command === '-h' || command === '--help') {
|
|
724
|
+
printHelp();
|
|
725
|
+
} else if (command === 'setup') {
|
|
726
|
+
setup(args);
|
|
727
|
+
} else if (command === 'init') {
|
|
728
|
+
return initProject(args);
|
|
729
|
+
} else if (command === 'doctor') {
|
|
730
|
+
cmdDoctor();
|
|
731
|
+
} else if (command === 'list') {
|
|
732
|
+
cmdList();
|
|
733
|
+
} else if (command === 'hooks') {
|
|
734
|
+
const subcommand = argv[1] || 'status';
|
|
735
|
+
if (subcommand === 'status') cmdHooksStatus();
|
|
736
|
+
else if (subcommand === 'install') cmdHooksInstall();
|
|
737
|
+
else {
|
|
738
|
+
console.error(`error: unknown hooks command: ${subcommand}`);
|
|
739
|
+
process.exitCode = 1;
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
console.error(`error: unknown command: ${command}\n`);
|
|
743
|
+
printHelp();
|
|
744
|
+
process.exitCode = 1;
|
|
745
|
+
}
|
|
228
746
|
}
|
|
229
747
|
|
|
230
748
|
const args = parseArgs(process.argv.slice(2));
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
749
|
+
async function main() {
|
|
750
|
+
await runCommand(process.argv.slice(2));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
main().catch((error) => {
|
|
754
|
+
console.error(error.message);
|
|
755
|
+
process.exitCode = 1;
|
|
756
|
+
});
|