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.
- package/README.md +134 -277
- package/README.zh-CN.md +134 -272
- package/dist/chunks/version-build.mjs +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +709 -16
- package/dist/index.d.ts +709 -16
- package/dist/index.mjs +1061 -30
- package/dist/shared/{ccgx-workflow.j1spUsik.mjs → ccgx-workflow.CdHnJLak.mjs} +106 -22
- package/package.json +1 -1
- package/templates/commands/agents/code-fixer.md +6 -6
- package/templates/commands/agents/phase-runner.md +46 -14
- package/templates/commands/agents/plan-checker.md +10 -0
- package/templates/commands/analyze.md +66 -25
- package/templates/commands/autonomous.md +428 -225
- package/templates/commands/cancel.md +9 -0
- package/templates/commands/codex-exec.md +12 -11
- package/templates/commands/context.md +14 -0
- package/templates/commands/debate.md +10 -6
- package/templates/commands/execute.md +76 -28
- package/templates/commands/optimize.md +53 -25
- package/templates/commands/plan.md +78 -28
- package/templates/commands/review.md +26 -19
- package/templates/commands/spec-impl.md +68 -127
- package/templates/commands/spec-plan.md +61 -82
- package/templates/commands/spec-research.md +35 -92
- package/templates/commands/spec-review.md +34 -119
- package/templates/commands/status.md +1 -0
- package/templates/commands/team-exec.md +45 -13
- package/templates/commands/team.md +64 -167
- package/templates/commands/test.md +56 -34
- package/templates/commands/verify-work.md +36 -13
- package/templates/commands/verify.md +35 -0
- package/templates/commands/workflow.md +22 -37
- package/templates/hooks/ccg-loop-detector.cjs +39 -8
- package/templates/hooks/ccg-statusline.js +142 -2
- package/templates/hooks/ccg-stop-gate.cjs +248 -19
- package/templates/hooks/ccg-subagent-context.cjs +505 -0
- package/templates/scripts/ccg-state-lock.cjs +510 -0
- package/templates/scripts/ccg-team-schedule.cjs +328 -0
- package/templates/scripts/ccgx-call-plugin.mjs +494 -141
- package/templates/scripts/invoke-model.mjs +28 -1
- package/templates/scripts/task-store.cjs +614 -0
- package/templates/skills/tools/verify-change/SKILL.md +7 -0
- package/templates/skills/tools/verify-module/SKILL.md +7 -0
- package/templates/skills/tools/verify-quality/SKILL.md +7 -0
- 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
|
-
|
|
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
|
|