ccgx-workflow 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +134 -277
  2. package/README.zh-CN.md +134 -272
  3. package/dist/chunks/version-build.mjs +1 -1
  4. package/dist/cli.mjs +1 -1
  5. package/dist/index.d.mts +709 -16
  6. package/dist/index.d.ts +709 -16
  7. package/dist/index.mjs +1061 -30
  8. package/dist/shared/{ccgx-workflow.j1spUsik.mjs → ccgx-workflow.CdHnJLak.mjs} +106 -22
  9. package/package.json +1 -1
  10. package/templates/commands/agents/code-fixer.md +6 -6
  11. package/templates/commands/agents/phase-runner.md +46 -14
  12. package/templates/commands/agents/plan-checker.md +10 -0
  13. package/templates/commands/analyze.md +66 -25
  14. package/templates/commands/autonomous.md +428 -225
  15. package/templates/commands/cancel.md +9 -0
  16. package/templates/commands/codex-exec.md +12 -11
  17. package/templates/commands/context.md +14 -0
  18. package/templates/commands/debate.md +10 -6
  19. package/templates/commands/execute.md +76 -28
  20. package/templates/commands/optimize.md +53 -25
  21. package/templates/commands/plan.md +78 -28
  22. package/templates/commands/review.md +26 -19
  23. package/templates/commands/spec-impl.md +68 -127
  24. package/templates/commands/spec-plan.md +61 -82
  25. package/templates/commands/spec-research.md +35 -92
  26. package/templates/commands/spec-review.md +34 -119
  27. package/templates/commands/status.md +1 -0
  28. package/templates/commands/team-exec.md +45 -13
  29. package/templates/commands/team.md +64 -167
  30. package/templates/commands/test.md +56 -34
  31. package/templates/commands/verify-work.md +36 -13
  32. package/templates/commands/verify.md +35 -0
  33. package/templates/commands/workflow.md +22 -37
  34. package/templates/hooks/ccg-loop-detector.cjs +39 -8
  35. package/templates/hooks/ccg-statusline.js +142 -2
  36. package/templates/hooks/ccg-stop-gate.cjs +248 -19
  37. package/templates/hooks/ccg-subagent-context.cjs +505 -0
  38. package/templates/scripts/ccg-state-lock.cjs +510 -0
  39. package/templates/scripts/ccg-team-schedule.cjs +328 -0
  40. package/templates/scripts/ccgx-call-plugin.mjs +494 -141
  41. package/templates/scripts/invoke-model.mjs +28 -1
  42. package/templates/scripts/task-store.cjs +614 -0
  43. package/templates/skills/tools/verify-change/SKILL.md +7 -0
  44. package/templates/skills/tools/verify-module/SKILL.md +7 -0
  45. package/templates/skills/tools/verify-quality/SKILL.md +7 -0
  46. package/templates/skills/tools/verify-security/SKILL.md +8 -0
@@ -186,6 +186,28 @@ function lookPath(cmd, opts = {}) {
186
186
  return cmd; // let spawn surface ENOENT
187
187
  }
188
188
 
189
+ // Render one argument for a `cmd.exe /d /s /c "<line>"` invocation spawned with
190
+ // windowsVerbatimArguments:true. Under verbatim mode Node joins args with
191
+ // single spaces and applies NO quoting, so cmd.exe re-parses the line and would
192
+ // act on metacharacters (& | < > ( ) % ") in any unquoted argument. Port of
193
+ // Rust std's append_bat_arg (.bat/.cmd CVE fix), validated against cmd.exe:
194
+ // wrap in literal quotes (metachars inert inside a quoted span + MSVCRT gets one
195
+ // token), MSVCRT-escape inner quotes as \", and emit % as "^%" so %VAR% is not
196
+ // expanded. Doubles backslash runs that precede a quote.
197
+ function cmdEscapeArg(arg) {
198
+ let out = '"';
199
+ let backslashes = 0;
200
+ for (const ch of String(arg)) {
201
+ if (ch === '%') { out += '"^%"'; backslashes = 0; continue; }
202
+ if (ch === '"') { out += '\\'.repeat(backslashes) + '\\"'; backslashes = 0; continue; }
203
+ if (ch === '\\') { backslashes += 1; out += ch; continue; }
204
+ backslashes = 0;
205
+ out += ch;
206
+ }
207
+ out += '\\'.repeat(backslashes) + '"';
208
+ return out;
209
+ }
210
+
189
211
  // Resolve `cmd` against PATH (POSIX) or PATH+PATHEXT (Windows).
190
212
  // Returns null when the binary is not found, giving callers a chance to emit
191
213
  // a friendly install hint before spawn raises ENOENT.
@@ -796,9 +818,14 @@ async function main() {
796
818
  // Windows: spawning .cmd/.bat directly throws EINVAL (Node CVE-2024-27980
797
819
  // mitigation). Wrap with cmd.exe /c to keep arg array semantics without
798
820
  // tripping DEP0190 (`shell:true + args[]` deprecation in Node 24+).
821
+ // windowsVerbatimArguments hands the line to cmd.exe with no quoting, so each
822
+ // caller arg must be cmd-escaped and the whole "<prog> <args>" wrapped in one
823
+ // outer quote pair that /s strips — otherwise cmd.exe would interpret
824
+ // metacharacters in argv content (command injection).
799
825
  if (IS_WINDOWS && /\.(cmd|bat)$/i.test(resolvedCommand)) {
800
826
  spawnOpts.windowsVerbatimArguments = true;
801
- resolvedArgs = ['/d', '/s', '/c', `"${resolvedCommand}"`, ...args];
827
+ const line = [`"${resolvedCommand}"`, ...args.map(cmdEscapeArg)].join(' ');
828
+ resolvedArgs = ['/d', '/s', '/c', `"${line}"`];
802
829
  resolvedCommand = process.env.ComSpec || 'cmd.exe';
803
830
  }
804
831
 
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env node
2
+ // CCG Task Store CLI — P1-4 (.context/tasks container + STATE.json projection).
3
+ //
4
+ // Standalone Node CJS reimplementation of src/utils/task-store.ts. The TS
5
+ // version ships in ccgx-workflow's dist for package consumers; but command
6
+ // templates (/ccg:review 5.4, /ccg:spec-impl Step 10d, future /ccg:autonomous)
7
+ // run in the USER's project, which has no ccgx-workflow npm dependency. The
8
+ // model can't `import` a helper that isn't installed.
9
+ //
10
+ // installer.ts copies this file to ~/.claude/.ccg/scripts/task-store.cjs
11
+ // (alongside spec-suggestion.cjs and friends). Command templates call:
12
+ //
13
+ // node ~/.claude/.ccg/scripts/task-store.cjs <subcommand> [flags]
14
+ //
15
+ // Subcommands:
16
+ // create --id <id> --title <t> [--branch --phase --next --total --done]
17
+ // get --id <id>
18
+ // list
19
+ // active
20
+ // update --id <id> [--title --status --phase --next --branch --total --done]
21
+ // register-artifact --type <t> --path <p> [--id <id>] [--status --note]
22
+ // complete --id <id> [--next <cmd>]
23
+ // (--next is archived to task.json only — the completed
24
+ // task is terminal, so it is NOT projected into
25
+ // STATE.json / the statusline; retrieve via `get --id`)
26
+ // archive --id <id>
27
+ // state
28
+ //
29
+ // All subcommands accept --workdir <dir> (default: process.cwd()).
30
+ //
31
+ // stdout is ALWAYS JSON: TaskRecord / TaskRecord[] / ProjectState /
32
+ // {ok:false, reason}. Exit 0 = success or benign no-op (e.g.
33
+ // register-artifact with no active task → {ok:false,reason:"no-active-task"}).
34
+ // Exit 1 = bad args / IO error / missing task on a mutating subcommand.
35
+ //
36
+ // Logic must stay in lockstep with src/utils/task-store.ts. The shared test
37
+ // cases in __tests__/taskStoreCjs.test.ts pin both implementations against
38
+ // the same fixtures.
39
+
40
+ 'use strict';
41
+
42
+ const fs = require('node:fs');
43
+ const path = require('node:path');
44
+ const crypto = require('node:crypto');
45
+
46
+ // ─────────────────────────────────────────────────────────────────
47
+ // Constants (must mirror task-store.ts)
48
+ // ─────────────────────────────────────────────────────────────────
49
+
50
+ const TASKS_RELATIVE_PATH = '.context/tasks';
51
+ const STATE_RELATIVE_PATH = '.context/STATE.json';
52
+
53
+ const TERMINAL_STATUS_SYNONYMS = new Set([
54
+ 'completed', 'complete', 'done', 'finished', 'closed',
55
+ 'archived', 'cancelled', 'canceled', 'abandoned', 'failed',
56
+ ]);
57
+
58
+ const VALID_ARTIFACT_TYPES = new Set([
59
+ 'fix-log', 'spec-evolution', 'review', 'plan', 'summary', 'generic',
60
+ ]);
61
+
62
+ const VALID_TASK_STATUSES = new Set(['in_progress', 'completed', 'archived', 'cancelled']);
63
+ const VALID_ARTIFACT_STATUSES = new Set(['active', 'final', 'stale']);
64
+
65
+ // ─────────────────────────────────────────────────────────────────
66
+ // Atomic write (mirrors jobs.ts atomicWriteFileSync)
67
+ // ─────────────────────────────────────────────────────────────────
68
+
69
+ function atomicWriteFileSync(target, content) {
70
+ const rand = crypto.randomBytes(6).toString('hex');
71
+ const tmp = `${target}.tmp.${rand}`;
72
+ try {
73
+ fs.writeFileSync(tmp, content, 'utf-8');
74
+ fs.renameSync(tmp, target);
75
+ } catch (err) {
76
+ try { fs.unlinkSync(tmp); } catch { /* nothing to clean up */ }
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────
82
+ // Path helpers
83
+ // ─────────────────────────────────────────────────────────────────
84
+
85
+ function tasksRoot(workdir) {
86
+ return path.join(workdir, '.context', 'tasks');
87
+ }
88
+
89
+ function taskDir(workdir, taskId) {
90
+ return path.join(tasksRoot(workdir), sanitizeTaskId(taskId));
91
+ }
92
+
93
+ function taskJsonPath(workdir, taskId) {
94
+ return path.join(taskDir(workdir, taskId), 'task.json');
95
+ }
96
+
97
+ function projectStatePath(workdir) {
98
+ return path.join(workdir, '.context', 'STATE.json');
99
+ }
100
+
101
+ function sanitizeTaskId(taskId) {
102
+ const trimmed = String(taskId == null ? '' : taskId).trim();
103
+ if (!trimmed) throw new Error('taskId cannot be empty');
104
+ const cleaned = trimmed.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
105
+ if (!cleaned) throw new Error(`taskId sanitizes to empty: ${taskId}`);
106
+ return cleaned;
107
+ }
108
+
109
+ // ─────────────────────────────────────────────────────────────────
110
+ // Status / progress helpers
111
+ // ─────────────────────────────────────────────────────────────────
112
+
113
+ function isTerminalStatus(status) {
114
+ if (typeof status !== 'string') return false;
115
+ return TERMINAL_STATUS_SYNONYMS.has(status.trim().toLowerCase());
116
+ }
117
+
118
+ function normalizeProgress(progress) {
119
+ if (!progress || typeof progress !== 'object') return undefined;
120
+ const total = progress.total_phases;
121
+ const done = progress.completed_phases;
122
+ if (typeof total !== 'number' || typeof done !== 'number') return undefined;
123
+ const percent = typeof progress.percent === 'number'
124
+ ? progress.percent
125
+ : (total > 0 ? Math.round((done / total) * 100) : 0);
126
+ return { total_phases: total, completed_phases: done, percent };
127
+ }
128
+
129
+ function normalizeArtifactPath(p) {
130
+ return p.replace(/\\/g, '/');
131
+ }
132
+
133
+ function validateTaskRecord(record) {
134
+ if (record === null || typeof record !== 'object') {
135
+ throw new Error('TaskRecord must be an object');
136
+ }
137
+ if (typeof record.id !== 'string' || !record.id) {
138
+ throw new Error('TaskRecord missing required field: id');
139
+ }
140
+ if (typeof record.status !== 'string' || !record.status) {
141
+ throw new Error('TaskRecord missing required field: status');
142
+ }
143
+ if (typeof record.created_at !== 'string' || !record.created_at) {
144
+ throw new Error('TaskRecord missing required field: created_at');
145
+ }
146
+ if (!Array.isArray(record.artifacts)) {
147
+ record.artifacts = [];
148
+ }
149
+ }
150
+
151
+ function writeTaskRecord(workdir, record) {
152
+ const dir = taskDir(workdir, record.id);
153
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
154
+ atomicWriteFileSync(taskJsonPath(workdir, record.id), JSON.stringify(record, null, 2));
155
+ }
156
+
157
+ // ─────────────────────────────────────────────────────────────────
158
+ // CRUD (mirrors task-store.ts)
159
+ // ─────────────────────────────────────────────────────────────────
160
+
161
+ function createTask(workdir, init) {
162
+ const id = sanitizeTaskId(init.id);
163
+ if (typeof init.title !== 'string' || !init.title.trim()) {
164
+ throw new Error('task title cannot be empty');
165
+ }
166
+ if (fs.existsSync(taskJsonPath(workdir, id))) {
167
+ throw new Error(`Task already exists: ${id}`);
168
+ }
169
+ const now = new Date().toISOString();
170
+ const record = {
171
+ schema_version: 1,
172
+ id,
173
+ title: init.title,
174
+ status: 'in_progress',
175
+ active_phase: init.active_phase != null ? init.active_phase : null,
176
+ next_action: init.next_action != null ? init.next_action : null,
177
+ created_at: now,
178
+ updated_at: now,
179
+ artifacts: [],
180
+ };
181
+ const progress = normalizeProgress(init.progress);
182
+ if (progress) record.progress = progress;
183
+ if (init.branch) record.branch = init.branch;
184
+ writeTaskRecord(workdir, record);
185
+ syncProjectState(workdir);
186
+ return record;
187
+ }
188
+
189
+ function getTask(workdir, taskId) {
190
+ const file = taskJsonPath(workdir, taskId);
191
+ if (!fs.existsSync(file)) return null;
192
+ const raw = fs.readFileSync(file, 'utf-8');
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(raw);
196
+ } catch (err) {
197
+ throw new Error(`Task ${taskId}: task.json is not valid JSON (${err.message})`);
198
+ }
199
+ validateTaskRecord(parsed);
200
+ return parsed;
201
+ }
202
+
203
+ function listTasks(workdir) {
204
+ const root = tasksRoot(workdir);
205
+ if (!fs.existsSync(root)) return [];
206
+ const tasks = [];
207
+ for (const id of fs.readdirSync(root)) {
208
+ if (id === 'archive') continue;
209
+ if (!fs.existsSync(taskJsonPath(workdir, id))) continue;
210
+ try {
211
+ const task = getTask(workdir, id);
212
+ if (task) tasks.push(task);
213
+ } catch {
214
+ // corrupt task dir — skip from list view
215
+ }
216
+ }
217
+ tasks.sort((a, b) => b.created_at.localeCompare(a.created_at));
218
+ return tasks;
219
+ }
220
+
221
+ function getActiveTask(workdir) {
222
+ for (const task of listTasks(workdir)) {
223
+ if (!isTerminalStatus(task.status)) return task;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ function updateTask(workdir, taskId, patch) {
229
+ const task = getTask(workdir, taskId);
230
+ if (!task) throw new Error(`Task not found: ${taskId}`);
231
+ if ('title' in patch && patch.title !== undefined) task.title = patch.title;
232
+ if ('status' in patch && patch.status !== undefined) task.status = patch.status;
233
+ if ('active_phase' in patch) task.active_phase = patch.active_phase != null ? patch.active_phase : null;
234
+ if ('next_action' in patch) task.next_action = patch.next_action != null ? patch.next_action : null;
235
+ if ('branch' in patch && patch.branch !== undefined) task.branch = patch.branch;
236
+ if ('progress' in patch) {
237
+ const progress = normalizeProgress(patch.progress);
238
+ if (progress) task.progress = progress;
239
+ else delete task.progress;
240
+ }
241
+ task.updated_at = new Date().toISOString();
242
+ writeTaskRecord(workdir, task);
243
+ syncProjectState(workdir);
244
+ return task;
245
+ }
246
+
247
+ function registerArtifact(workdir, taskId, artifact) {
248
+ if (!VALID_ARTIFACT_TYPES.has(artifact.type)) {
249
+ throw new TypeError(`invalid artifact type: ${String(artifact.type)}`);
250
+ }
251
+ if (typeof artifact.path !== 'string' || !artifact.path.trim()) {
252
+ throw new TypeError('artifact path cannot be empty');
253
+ }
254
+ const task = getTask(workdir, taskId);
255
+ if (!task) throw new Error(`Task not found: ${taskId}`);
256
+ const normPath = normalizeArtifactPath(artifact.path.trim());
257
+ const existing = task.artifacts.find(a => a.type === artifact.type && a.path === normPath);
258
+ if (existing) {
259
+ if (artifact.status) existing.status = artifact.status;
260
+ if (artifact.note !== undefined) existing.note = artifact.note;
261
+ } else {
262
+ const entry = {
263
+ type: artifact.type,
264
+ path: normPath,
265
+ status: artifact.status || 'active',
266
+ registered_at: new Date().toISOString(),
267
+ };
268
+ if (artifact.note !== undefined) entry.note = artifact.note;
269
+ task.artifacts.push(entry);
270
+ }
271
+ task.updated_at = new Date().toISOString();
272
+ writeTaskRecord(workdir, task);
273
+ syncProjectState(workdir);
274
+ return task;
275
+ }
276
+
277
+ function registerArtifactForActiveTask(workdir, artifact) {
278
+ try {
279
+ const active = getActiveTask(workdir);
280
+ if (!active) return null;
281
+ return registerArtifact(workdir, active.id, artifact);
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ function registerSpecEvolution(workdir, changeId) {
288
+ return registerArtifactForActiveTask(workdir, {
289
+ type: 'spec-evolution',
290
+ path: `openspec/changes/${changeId}/SPEC_EVOLUTION.md`,
291
+ });
292
+ }
293
+
294
+ function completeTask(workdir, taskId, opts) {
295
+ const task = getTask(workdir, taskId);
296
+ if (!task) throw new Error(`Task not found: ${taskId}`);
297
+ task.status = 'completed';
298
+ task.completed_at = new Date().toISOString();
299
+ task.active_phase = null;
300
+ if (opts && 'next_action' in opts) {
301
+ task.next_action = opts.next_action != null ? opts.next_action : null;
302
+ }
303
+ task.updated_at = task.completed_at;
304
+ writeTaskRecord(workdir, task);
305
+ syncProjectState(workdir);
306
+ return task;
307
+ }
308
+
309
+ function archiveTask(workdir, taskId, now) {
310
+ const task = getTask(workdir, taskId);
311
+ if (!task) return null;
312
+ const ts = now instanceof Date ? now : new Date();
313
+ task.status = 'archived';
314
+ task.updated_at = new Date().toISOString();
315
+ writeTaskRecord(workdir, task);
316
+ const yearMonth = ts.toISOString().slice(0, 7);
317
+ const destParent = path.join(tasksRoot(workdir), 'archive', yearMonth);
318
+ const dest = path.join(destParent, sanitizeTaskId(taskId));
319
+ try {
320
+ fs.mkdirSync(destParent, { recursive: true });
321
+ fs.renameSync(taskDir(workdir, taskId), dest);
322
+ } catch {
323
+ syncProjectState(workdir);
324
+ return null;
325
+ }
326
+ syncProjectState(workdir);
327
+ return dest;
328
+ }
329
+
330
+ // ─────────────────────────────────────────────────────────────────
331
+ // Project state projection
332
+ // ─────────────────────────────────────────────────────────────────
333
+
334
+ function readProjectState(workdir) {
335
+ const file = projectStatePath(workdir);
336
+ if (!fs.existsSync(file)) return null;
337
+ try {
338
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
339
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
340
+ return parsed;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ function writeProjectState(workdir, partial) {
347
+ const state = {
348
+ schema_version: 1,
349
+ active_task_id: partial.active_task_id != null ? partial.active_task_id : null,
350
+ active_phase: partial.active_phase != null ? partial.active_phase : null,
351
+ next_action: partial.next_action != null ? partial.next_action : null,
352
+ progress: normalizeProgress(partial.progress) || null,
353
+ updated_at: new Date().toISOString(),
354
+ };
355
+ if (partial.status !== undefined) state.status = partial.status;
356
+ const file = projectStatePath(workdir);
357
+ fs.mkdirSync(path.dirname(file), { recursive: true });
358
+ atomicWriteFileSync(file, JSON.stringify(state, null, 2));
359
+ return state;
360
+ }
361
+
362
+ function syncProjectState(workdir) {
363
+ let active = null;
364
+ try {
365
+ active = getActiveTask(workdir);
366
+ } catch {
367
+ active = null;
368
+ }
369
+ if (!active) {
370
+ return writeProjectState(workdir, {
371
+ active_task_id: null, active_phase: null, next_action: null, progress: null,
372
+ });
373
+ }
374
+ return writeProjectState(workdir, {
375
+ active_task_id: active.id,
376
+ active_phase: active.active_phase != null ? active.active_phase : null,
377
+ next_action: active.next_action != null ? active.next_action : null,
378
+ progress: active.progress || null,
379
+ });
380
+ }
381
+
382
+ // ─────────────────────────────────────────────────────────────────
383
+ // CLI
384
+ // ─────────────────────────────────────────────────────────────────
385
+
386
+ const SUBCOMMANDS = new Set([
387
+ 'create', 'get', 'list', 'active', 'update',
388
+ 'register-artifact', 'complete', 'archive', 'state',
389
+ ]);
390
+
391
+ function parseArgs(argv) {
392
+ // argv = full process.argv (node, script, subcommand, flags...)
393
+ const out = {
394
+ command: null,
395
+ id: null,
396
+ title: null,
397
+ branch: null,
398
+ phase: undefined,
399
+ next: undefined,
400
+ status: null,
401
+ total: null,
402
+ done: null,
403
+ type: null,
404
+ path: null,
405
+ note: null,
406
+ workdir: process.cwd(),
407
+ help: false,
408
+ };
409
+ const flagMap = {
410
+ '--id': 'id',
411
+ '--title': 'title',
412
+ '--branch': 'branch',
413
+ '--phase': 'phase',
414
+ '--next': 'next',
415
+ '--status': 'status',
416
+ '--type': 'type',
417
+ '--path': 'path',
418
+ '--note': 'note',
419
+ '--workdir': 'workdir',
420
+ };
421
+ for (let i = 2; i < argv.length; i++) {
422
+ const a = argv[i];
423
+ if (a === '--help' || a === '-h') {
424
+ out.help = true;
425
+ } else if (flagMap[a] && i + 1 < argv.length) {
426
+ out[flagMap[a]] = argv[++i];
427
+ } else if ((a === '--total' || a === '--done') && i + 1 < argv.length) {
428
+ const n = Number.parseInt(argv[++i], 10);
429
+ if (Number.isNaN(n) || n < 0) {
430
+ process.stderr.write(`task-store: ${a} must be a non-negative integer\n`);
431
+ out.help = true;
432
+ } else {
433
+ out[a === '--total' ? 'total' : 'done'] = n;
434
+ }
435
+ } else if (!a.startsWith('-') && out.command === null) {
436
+ out.command = a;
437
+ } else {
438
+ process.stderr.write(`task-store: unknown arg: ${a}\n`);
439
+ out.help = true;
440
+ }
441
+ }
442
+ return out;
443
+ }
444
+
445
+ function printUsage() {
446
+ process.stderr.write([
447
+ 'Usage: node task-store.cjs <subcommand> [flags]',
448
+ '',
449
+ 'Subcommands: create | get | list | active | update | register-artifact',
450
+ ' | complete | archive | state',
451
+ '',
452
+ 'Flags: --id --title --branch --phase --next --status --total --done',
453
+ ' --type --path --note --workdir (default: cwd)',
454
+ '',
455
+ 'Note: complete --next is archived to task.json only — it is NOT projected',
456
+ 'into STATE.json / the statusline (the task is terminal); use `get --id`.',
457
+ '',
458
+ 'stdout is always JSON. Exit 0 = success or benign no-op',
459
+ '(e.g. register-artifact with no active task). Exit 1 = bad args / IO error.',
460
+ ].join('\n') + '\n');
461
+ }
462
+
463
+ function emit(value) {
464
+ process.stdout.write(JSON.stringify(value, null, 2) + '\n');
465
+ }
466
+
467
+ function buildProgress(args) {
468
+ if (args.total === null && args.done === null) return undefined;
469
+ if (args.total === null || args.done === null) {
470
+ throw new Error('--total and --done must be provided together');
471
+ }
472
+ return { total_phases: args.total, completed_phases: args.done };
473
+ }
474
+
475
+ function main(argv) {
476
+ const args = parseArgs(argv);
477
+ if (args.help || !args.command || !SUBCOMMANDS.has(args.command)) {
478
+ printUsage();
479
+ return 1;
480
+ }
481
+ const workdir = args.workdir;
482
+ try {
483
+ switch (args.command) {
484
+ case 'create': {
485
+ if (!args.id || !args.title) throw new Error('create requires --id and --title');
486
+ emit(createTask(workdir, {
487
+ id: args.id,
488
+ title: args.title,
489
+ branch: args.branch || undefined,
490
+ active_phase: args.phase,
491
+ next_action: args.next,
492
+ progress: buildProgress(args),
493
+ }));
494
+ return 0;
495
+ }
496
+ case 'get': {
497
+ if (!args.id) throw new Error('get requires --id');
498
+ const task = getTask(workdir, args.id);
499
+ emit(task || { ok: false, reason: 'not-found' });
500
+ return 0;
501
+ }
502
+ case 'list': {
503
+ emit(listTasks(workdir));
504
+ return 0;
505
+ }
506
+ case 'active': {
507
+ const active = getActiveTask(workdir);
508
+ emit(active || { ok: false, reason: 'no-active-task' });
509
+ return 0;
510
+ }
511
+ case 'update': {
512
+ if (!args.id) throw new Error('update requires --id');
513
+ if (args.status !== null && !VALID_TASK_STATUSES.has(args.status)) {
514
+ throw new Error(`invalid task status: ${args.status} (expected ${[...VALID_TASK_STATUSES].join('|')})`);
515
+ }
516
+ const patch = {};
517
+ if (args.title !== null) patch.title = args.title;
518
+ if (args.status !== null) patch.status = args.status;
519
+ if (args.phase !== undefined) patch.active_phase = args.phase;
520
+ if (args.next !== undefined) patch.next_action = args.next;
521
+ if (args.branch !== null) patch.branch = args.branch;
522
+ const progress = buildProgress(args);
523
+ if (progress) patch.progress = progress;
524
+ emit(updateTask(workdir, args.id, patch));
525
+ return 0;
526
+ }
527
+ case 'register-artifact': {
528
+ if (!args.type || !args.path) throw new Error('register-artifact requires --type and --path');
529
+ // Validate eagerly here: registerArtifactForActiveTask swallows all
530
+ // errors (advisory contract), but a bad ARGUMENT must still exit 1.
531
+ if (!VALID_ARTIFACT_TYPES.has(args.type)) {
532
+ throw new Error(`invalid artifact type: ${args.type} (expected ${[...VALID_ARTIFACT_TYPES].join('|')})`);
533
+ }
534
+ if (args.status !== null && !VALID_ARTIFACT_STATUSES.has(args.status)) {
535
+ throw new Error(`invalid artifact status: ${args.status} (expected ${[...VALID_ARTIFACT_STATUSES].join('|')})`);
536
+ }
537
+ const artifact = { type: args.type, path: args.path };
538
+ if (args.status !== null) artifact.status = args.status;
539
+ if (args.note !== null) artifact.note = args.note;
540
+ if (args.id) {
541
+ emit(registerArtifact(workdir, args.id, artifact));
542
+ return 0;
543
+ }
544
+ const result = registerArtifactForActiveTask(workdir, artifact);
545
+ // Benign no-op: no active task to pin the pointer on. Exit 0 so
546
+ // command templates can call this unconditionally without guards.
547
+ emit(result || { ok: false, reason: 'no-active-task' });
548
+ return 0;
549
+ }
550
+ case 'complete': {
551
+ if (!args.id) throw new Error('complete requires --id');
552
+ const opts = args.next !== undefined ? { next_action: args.next } : undefined;
553
+ emit(completeTask(workdir, args.id, opts));
554
+ return 0;
555
+ }
556
+ case 'archive': {
557
+ if (!args.id) throw new Error('archive requires --id');
558
+ const dest = archiveTask(workdir, args.id);
559
+ emit(dest ? { ok: true, archived_to: dest } : { ok: false, reason: 'not-archived' });
560
+ return 0;
561
+ }
562
+ case 'state': {
563
+ const state = readProjectState(workdir);
564
+ emit(state || { ok: false, reason: 'no-state' });
565
+ return 0;
566
+ }
567
+ default: {
568
+ printUsage();
569
+ return 1;
570
+ }
571
+ }
572
+ } catch (err) {
573
+ process.stderr.write(`task-store: ${err.message}\n`);
574
+ return 1;
575
+ }
576
+ }
577
+
578
+ if (require.main === module) {
579
+ process.exit(main(process.argv));
580
+ }
581
+
582
+ module.exports = {
583
+ // constants
584
+ TASKS_RELATIVE_PATH,
585
+ STATE_RELATIVE_PATH,
586
+ // path helpers
587
+ tasksRoot,
588
+ taskDir,
589
+ taskJsonPath,
590
+ projectStatePath,
591
+ sanitizeTaskId,
592
+ // status helpers
593
+ isTerminalStatus,
594
+ // CRUD
595
+ createTask,
596
+ getTask,
597
+ listTasks,
598
+ getActiveTask,
599
+ updateTask,
600
+ // artifacts
601
+ registerArtifact,
602
+ registerArtifactForActiveTask,
603
+ registerSpecEvolution,
604
+ // lifecycle
605
+ completeTask,
606
+ archiveTask,
607
+ // projection
608
+ readProjectState,
609
+ writeProjectState,
610
+ syncProjectState,
611
+ // CLI
612
+ parseArgs,
613
+ main,
614
+ };
@@ -10,10 +10,17 @@ argument-hint: [--mode working|staged|committed]
10
10
  deprecated_in: 1.0.0
11
11
  replaced_by: /ccg:verify --gate=change
12
12
  deprecation_message: 推荐使用 `/ccg:verify --gate=change`。本命令仍可用以保持 BC。详见 .ccg-migration/DEPRECATIONS.md
13
+ gate-type: revision-loop
13
14
  ---
14
15
 
15
16
  # ⚖ 校验关卡 · 变更校验
16
17
 
18
+ ## Gate 分类与失败路径
19
+
20
+ - **类型**:revision-loop(maxLoops = 3)
21
+ - **失败路径**:findings 回喂修复 → 重跑本门;3 轮不收敛或连续两轮 issue 数不下降 → escalation 三选(force / guide / abort)
22
+ - **单一真相源**:分类与失败路径以 `/ccg:verify` 的 Gates Taxonomy 段及 `src/utils/verify-orchestrator.ts` 的 `GATE_REGISTRY` 为准
23
+
17
24
 
18
25
  ## 核心原则
19
26
 
@@ -10,10 +10,17 @@ argument-hint: <模块路径>
10
10
  deprecated_in: 1.0.0
11
11
  replaced_by: /ccg:verify --gate=module
12
12
  deprecation_message: 推荐使用 `/ccg:verify --gate=module`。本命令仍可用以保持 BC。详见 .ccg-migration/DEPRECATIONS.md
13
+ gate-type: revision-loop
13
14
  ---
14
15
 
15
16
  # ⚖ 校验关卡 · 模块完整性
16
17
 
18
+ ## Gate 分类与失败路径
19
+
20
+ - **类型**:revision-loop(maxLoops = 3)
21
+ - **失败路径**:缺文档/结构 → 补齐后重跑本门;3 轮不收敛 → escalation 三选(force / guide / abort)
22
+ - **单一真相源**:分类与失败路径以 `/ccg:verify` 的 Gates Taxonomy 段及 `src/utils/verify-orchestrator.ts` 的 `GATE_REGISTRY` 为准
23
+
17
24
 
18
25
  ## 核心原则
19
26