cclaw-cli 0.5.15 → 0.5.17

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 CHANGED
@@ -120,6 +120,12 @@ Required repository secret:
120
120
  └── runs/ # archived feature snapshots (YYYY-MM-DD-feature-name)
121
121
  ```
122
122
 
123
+ ## Harness Integration
124
+
125
+ Supported harnesses: `claude`, `cursor`, `opencode`, `codex`. The full
126
+ per-harness install surface, feature matrix, and lifecycle details live in
127
+ [docs/harnesses.md](./docs/harnesses.md).
128
+
123
129
  ## License
124
130
 
125
131
  [MIT](./LICENSE)
@@ -18,3 +18,16 @@ export declare function validateReviewArmy(projectRoot: string): Promise<{
18
18
  valid: boolean;
19
19
  errors: string[];
20
20
  }>;
21
+ export interface ReviewVerdictConsistencyResult {
22
+ ok: boolean;
23
+ errors: string[];
24
+ finalVerdict: "APPROVED" | "APPROVED_WITH_CONCERNS" | "BLOCKED" | "UNKNOWN";
25
+ openCriticalCount: number;
26
+ shipBlockerCount: number;
27
+ }
28
+ /**
29
+ * Ensure the narrative verdict in 07-review.md is consistent with the
30
+ * structured review-army reconciliation. A review cannot declare
31
+ * APPROVED while open Critical findings or shipBlockers remain.
32
+ */
33
+ export declare function checkReviewVerdictConsistency(projectRoot: string): Promise<ReviewVerdictConsistencyResult>;
@@ -134,7 +134,61 @@ function extractRequiredKeywords(rule) {
134
134
  return [];
135
135
  return phrases;
136
136
  }
137
- function validateSectionBody(sectionBody, rule) {
137
+ const VAGUE_AC_ADJECTIVES = [
138
+ "fast",
139
+ "quick",
140
+ "slow",
141
+ "fast enough",
142
+ "quickly",
143
+ "intuitive",
144
+ "robust",
145
+ "reliable",
146
+ "scalable",
147
+ "simple",
148
+ "easy",
149
+ "user-friendly",
150
+ "user friendly",
151
+ "nice",
152
+ "good",
153
+ "clean",
154
+ "secure enough",
155
+ "responsive",
156
+ "efficient",
157
+ "performant",
158
+ "smooth",
159
+ "seamless",
160
+ "modern"
161
+ ];
162
+ function isSeparatorRow(line) {
163
+ return /^\|[-:| ]+\|$/u.test(line);
164
+ }
165
+ function getMarkdownTableRows(sectionBody) {
166
+ const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
167
+ const rows = [];
168
+ let sawSeparator = false;
169
+ for (const line of lines) {
170
+ if (!/^\|.*\|$/u.test(line))
171
+ continue;
172
+ if (isSeparatorRow(line)) {
173
+ sawSeparator = true;
174
+ continue;
175
+ }
176
+ if (!sawSeparator)
177
+ continue;
178
+ rows.push(parseMarkdownTableRow(line));
179
+ }
180
+ return rows;
181
+ }
182
+ function lineContainsVagueAdjective(text) {
183
+ const lower = text.toLowerCase();
184
+ for (const adjective of VAGUE_AC_ADJECTIVES) {
185
+ const pattern = new RegExp(`(?:^|[^A-Za-z])${adjective.replace(/ /g, "\\s+")}(?:[^A-Za-z]|$)`, "iu");
186
+ if (pattern.test(lower))
187
+ return adjective;
188
+ }
189
+ return null;
190
+ }
191
+ function validateSectionBody(sectionBody, rule, sectionName) {
138
192
  const bodyLines = sectionBody.split(/\r?\n/).map((line) => line.trim());
139
193
  const meaningful = meaningfulLineCount(sectionBody);
140
194
  if (meaningful === 0) {
@@ -231,6 +285,29 @@ function validateSectionBody(sectionBody, rule) {
231
285
  };
232
286
  }
233
287
  }
288
+ if (normalizeHeadingTitle(sectionName).toLowerCase() === "acceptance criteria" &&
289
+ /observable[\s,]*measurable[\s,]+(and )?falsifiable/iu.test(rule)) {
290
+ const rows = getMarkdownTableRows(sectionBody);
291
+ for (const row of rows) {
292
+ const criterionText = row[1] ?? row[0] ?? "";
293
+ const adjective = lineContainsVagueAdjective(criterionText);
294
+ if (adjective) {
295
+ return {
296
+ ok: false,
297
+ details: `Acceptance criterion uses vague adjective "${adjective}" without a measurable predicate: "${criterionText.slice(0, 140)}". Rewrite with a numeric threshold or boolean outcome.`
298
+ };
299
+ }
300
+ const hasDigit = /\d/u.test(criterionText);
301
+ const hasMeasurableVerb = /\b(blocks?|rejects?|returns?|matches?|equals?|emits?|succeeds?|fails?|publishes?|logs?|persists?|reads?|writes?|creates?|deletes?|throws?|contains?|restores?|exceeds?|responds?|warns?|quarantines?|includes?|raises?|passes?|denies|refuses|exits|succeeds|completes|prevents|allows|maps|points|signals|surfaces|records|produces|accepts|requires)\b/iu.test(criterionText);
302
+ const hasMeaningfulText = /[A-Za-z]/u.test(criterionText) && criterionText.trim().length >= 12;
303
+ if (hasMeaningfulText && !hasDigit && !hasMeasurableVerb) {
304
+ return {
305
+ ok: false,
306
+ details: `Acceptance criterion lacks a measurable predicate (no numeric threshold, no observable verb like blocks/returns/publishes/matches): "${criterionText.slice(0, 140)}". Rewrite so the criterion is falsifiable by a single test.`
307
+ };
308
+ }
309
+ }
310
+ }
234
311
  return {
235
312
  ok: true,
236
313
  details: "Section heading and content satisfy lint heuristics."
@@ -273,7 +350,7 @@ export async function lintArtifact(projectRoot, stage) {
273
350
  const body = hasHeading ? sectionBodyByName(sections, v.section) : null;
274
351
  const validation = body === null
275
352
  ? { ok: false, details: `No ## heading matching required section "${v.section}".` }
276
- : validateSectionBody(body, v.validationRule);
353
+ : validateSectionBody(body, v.validationRule, v.section);
277
354
  const found = hasHeading && validation.ok;
278
355
  findings.push({
279
356
  section: v.section,
@@ -384,18 +461,19 @@ export async function validateReviewArmy(projectRoot) {
384
461
  if (!isStringArray(o.reportedBy) || o.reportedBy.length === 0) {
385
462
  errors.push(`findings[${i}].reportedBy must be a non-empty string array.`);
386
463
  }
387
- if (o.location !== undefined) {
388
- if (o.location === null || typeof o.location !== "object" || Array.isArray(o.location)) {
389
- errors.push(`findings[${i}].location must be an object when present.`);
464
+ if (o.location === undefined || o.location === null) {
465
+ errors.push(`findings[${i}].location is required and must be an object with file + line.`);
466
+ }
467
+ else if (typeof o.location !== "object" || Array.isArray(o.location)) {
468
+ errors.push(`findings[${i}].location must be an object with file + line.`);
469
+ }
470
+ else {
471
+ const loc = o.location;
472
+ if (!isNonEmptyString(loc.file)) {
473
+ errors.push(`findings[${i}].location.file must be a non-empty string.`);
390
474
  }
391
- else {
392
- const loc = o.location;
393
- if (!isNonEmptyString(loc.file)) {
394
- errors.push(`findings[${i}].location.file must be a non-empty string.`);
395
- }
396
- if (!isFiniteNumber(loc.line) || loc.line < 1) {
397
- errors.push(`findings[${i}].location.line must be a positive number.`);
398
- }
475
+ if (!isFiniteNumber(loc.line) || loc.line < 1) {
476
+ errors.push(`findings[${i}].location.line must be a positive number.`);
399
477
  }
400
478
  }
401
479
  if (o.recommendation !== undefined && !isNonEmptyString(o.recommendation)) {
@@ -445,6 +523,21 @@ export async function validateReviewArmy(projectRoot) {
445
523
  for (const msId of rec.multiSpecialistConfirmed) {
446
524
  if (!findingIds.has(msId)) {
447
525
  errors.push(`reconciliation.multiSpecialistConfirmed references unknown finding id "${msId}".`);
526
+ continue;
527
+ }
528
+ if (Array.isArray(root.findings)) {
529
+ const finding = root.findings.find((f) => {
530
+ return f && typeof f === "object" && !Array.isArray(f) && f.id === msId;
531
+ });
532
+ if (finding && typeof finding === "object" && !Array.isArray(finding)) {
533
+ const reportedBy = finding.reportedBy;
534
+ const count = Array.isArray(reportedBy)
535
+ ? new Set(reportedBy.filter((v) => typeof v === "string")).size
536
+ : 0;
537
+ if (count < 2) {
538
+ errors.push(`reconciliation.multiSpecialistConfirmed entry "${msId}" must be confirmed by at least 2 distinct reviewers (found ${count}).`);
539
+ }
540
+ }
448
541
  }
449
542
  }
450
543
  }
@@ -474,3 +567,79 @@ export async function validateReviewArmy(projectRoot) {
474
567
  }
475
568
  return { valid: errors.length === 0, errors };
476
569
  }
570
+ /**
571
+ * Ensure the narrative verdict in 07-review.md is consistent with the
572
+ * structured review-army reconciliation. A review cannot declare
573
+ * APPROVED while open Critical findings or shipBlockers remain.
574
+ */
575
+ export async function checkReviewVerdictConsistency(projectRoot) {
576
+ const errors = [];
577
+ const reviewMdPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "07-review.md");
578
+ const armyJsonPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "07-review-army.json");
579
+ let finalVerdict = "UNKNOWN";
580
+ if (await exists(reviewMdPath)) {
581
+ const raw = await fs.readFile(reviewMdPath, "utf8");
582
+ const sections = extractH2Sections(raw);
583
+ const verdictBody = sectionBodyByName(sections, "Final Verdict");
584
+ if (verdictBody) {
585
+ const chosen = [];
586
+ for (const token of ["APPROVED_WITH_CONCERNS", "APPROVED", "BLOCKED"]) {
587
+ const regex = new RegExp(`\\b${token}\\b`, "u");
588
+ if (regex.test(verdictBody)) {
589
+ // APPROVED would match inside APPROVED_WITH_CONCERNS; prefer the longer match first.
590
+ if (token === "APPROVED" && /\bAPPROVED_WITH_CONCERNS\b/u.test(verdictBody))
591
+ continue;
592
+ chosen.push(token);
593
+ }
594
+ }
595
+ if (chosen.length === 1) {
596
+ finalVerdict = chosen[0];
597
+ }
598
+ else if (chosen.length > 1) {
599
+ errors.push(`Final Verdict section lists multiple verdict tokens (${chosen.join(", ")}). Select exactly one.`);
600
+ }
601
+ else {
602
+ errors.push('Final Verdict section does not select APPROVED, APPROVED_WITH_CONCERNS, or BLOCKED.');
603
+ }
604
+ }
605
+ else {
606
+ errors.push('07-review.md is missing the "## Final Verdict" section.');
607
+ }
608
+ }
609
+ let openCriticalCount = 0;
610
+ let shipBlockerCount = 0;
611
+ if (await exists(armyJsonPath)) {
612
+ try {
613
+ const raw = await fs.readFile(armyJsonPath, "utf8");
614
+ const parsed = JSON.parse(raw);
615
+ const findings = Array.isArray(parsed.findings) ? parsed.findings : [];
616
+ for (const f of findings) {
617
+ if (!f || typeof f !== "object" || Array.isArray(f))
618
+ continue;
619
+ const o = f;
620
+ if (o.severity === "Critical" && o.status === "open") {
621
+ openCriticalCount++;
622
+ }
623
+ }
624
+ const rec = parsed.reconciliation && typeof parsed.reconciliation === "object" && !Array.isArray(parsed.reconciliation)
625
+ ? parsed.reconciliation
626
+ : null;
627
+ if (rec && Array.isArray(rec.shipBlockers)) {
628
+ shipBlockerCount = rec.shipBlockers.filter((v) => typeof v === "string").length;
629
+ }
630
+ }
631
+ catch {
632
+ // JSON validity is the concern of validateReviewArmy; skip silently here.
633
+ }
634
+ }
635
+ if (finalVerdict === "APPROVED" && (openCriticalCount > 0 || shipBlockerCount > 0)) {
636
+ errors.push(`Final Verdict is APPROVED but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Use BLOCKED or APPROVED_WITH_CONCERNS.`);
637
+ }
638
+ return {
639
+ ok: errors.length === 0,
640
+ errors,
641
+ finalVerdict,
642
+ openCriticalCount,
643
+ shipBlockerCount
644
+ };
645
+ }
package/dist/cli.d.ts CHANGED
@@ -6,7 +6,10 @@ interface ParsedArgs {
6
6
  harnesses?: HarnessId[];
7
7
  reconcileGates?: boolean;
8
8
  archiveName?: string;
9
+ showHelp?: boolean;
10
+ showVersion?: boolean;
9
11
  }
12
+ export declare function usage(): string;
10
13
  declare function parseHarnesses(raw: string): HarnessId[];
11
14
  declare function parseArgs(argv: string[]): ParsedArgs;
12
15
  export { parseArgs, parseHarnesses };
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
3
  import process from "node:process";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -9,18 +9,63 @@ import { initCclaw, syncCclaw, uninstallCclaw, upgradeCclaw } from "./install.js
9
9
  import { error, info } from "./logger.js";
10
10
  import { archiveRun } from "./runs.js";
11
11
  const INSTALLER_COMMANDS = ["init", "sync", "doctor", "upgrade", "uninstall", "archive"];
12
- function usage() {
12
+ export function usage() {
13
13
  return `cclaw - installer-first flow toolkit
14
14
 
15
15
  Usage:
16
- cclaw init [--harnesses=claude,cursor,opencode,codex]
17
- cclaw sync
18
- cclaw doctor [--reconcile-gates]
19
- cclaw archive [--name=feature-name]
20
- cclaw upgrade
21
- cclaw uninstall
16
+ cclaw <command> [flags]
17
+ cclaw --help | -h
18
+ cclaw --version | -v
19
+
20
+ Commands:
21
+ init Bootstrap .cclaw runtime, state, and harness shims in this project.
22
+ Flags: --harnesses=<list> Comma list of harnesses (claude,cursor,opencode,codex).
23
+ sync Regenerate harness shim files from the current .cclaw config (non-destructive).
24
+ doctor Run health checks against the local .cclaw runtime. Exit code 2 on failure.
25
+ Flags: --reconcile-gates Recompute current-stage gate evidence before checks.
26
+ archive Move .cclaw/artifacts into .cclaw/runs/<date>-<slug> and reset flow state.
27
+ Flags: --name=<feature> Feature slug (default: inferred from 00-idea.md).
28
+ upgrade Refresh generated files in .cclaw without modifying user artifacts.
29
+ uninstall Remove .cclaw runtime and the generated harness shim files.
30
+
31
+ Global flags:
32
+ -h, --help Show this help message and exit 0.
33
+ -v, --version Print the cclaw CLI version and exit 0.
34
+
35
+ Examples:
36
+ cclaw init --harnesses=claude,cursor
37
+ cclaw doctor --reconcile-gates
38
+ cclaw archive --name=payments-revamp
39
+
40
+ Docs: https://github.com/zuevrs/cclaw
41
+ Issues: https://github.com/zuevrs/cclaw/issues
22
42
  `;
23
43
  }
44
+ function cliPackageVersion() {
45
+ try {
46
+ const here = path.dirname(fileURLToPath(import.meta.url));
47
+ const candidates = [
48
+ path.resolve(here, "../package.json"),
49
+ path.resolve(here, "../../package.json")
50
+ ];
51
+ for (const candidate of candidates) {
52
+ try {
53
+ const raw = readFileSync(candidate, "utf8");
54
+ const parsed = JSON.parse(raw);
55
+ if (parsed.name === "cclaw-cli" && typeof parsed.version === "string") {
56
+ return parsed.version;
57
+ }
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ }
63
+ }
64
+ catch {
65
+ // fall through
66
+ }
67
+ return "unknown";
68
+ }
24
69
  function parseHarnesses(raw) {
25
70
  const requested = raw
26
71
  .split(",")
@@ -33,11 +78,19 @@ function parseHarnesses(raw) {
33
78
  return requested;
34
79
  }
35
80
  function parseArgs(argv) {
36
- const [commandRaw, ...flags] = argv;
37
- const command = INSTALLER_COMMANDS.includes(commandRaw)
81
+ const parsed = {};
82
+ const helpFlag = argv.find((arg) => arg === "--help" || arg === "-h");
83
+ if (helpFlag) {
84
+ parsed.showHelp = true;
85
+ }
86
+ const versionFlag = argv.find((arg) => arg === "--version" || arg === "-v");
87
+ if (versionFlag) {
88
+ parsed.showVersion = true;
89
+ }
90
+ const [commandRaw, ...flags] = argv.filter((arg) => arg !== "--help" && arg !== "-h" && arg !== "--version" && arg !== "-v");
91
+ parsed.command = INSTALLER_COMMANDS.includes(commandRaw)
38
92
  ? commandRaw
39
93
  : undefined;
40
- const parsed = { command };
41
94
  for (const flag of flags) {
42
95
  if (flag.startsWith("--harnesses=")) {
43
96
  parsed.harnesses = parseHarnesses(flag.replace("--harnesses=", ""));
@@ -54,6 +107,14 @@ function parseArgs(argv) {
54
107
  return parsed;
55
108
  }
56
109
  async function runCommand(parsed, ctx) {
110
+ if (parsed.showHelp) {
111
+ ctx.stdout.write(usage());
112
+ return 0;
113
+ }
114
+ if (parsed.showVersion) {
115
+ ctx.stdout.write(`cclaw ${cliPackageVersion()}\n`);
116
+ return 0;
117
+ }
57
118
  const command = parsed.command;
58
119
  if (!command) {
59
120
  ctx.stderr.write(usage());
@@ -88,7 +149,10 @@ async function runCommand(parsed, ctx) {
88
149
  }
89
150
  if (command === "archive") {
90
151
  const archived = await archiveRun(ctx.cwd, parsed.archiveName);
91
- info(ctx, `Archived active artifacts to ${archived.archivePath}. Flow state reset to brainstorm.`);
152
+ const snapshotSummary = archived.snapshottedStateFiles.length > 0
153
+ ? ` Snapshotted ${archived.snapshottedStateFiles.length} state file(s) under ${archived.archivePath}/state and wrote archive-manifest.json.`
154
+ : "";
155
+ info(ctx, `Archived active artifacts to ${archived.archivePath}. Flow state reset to brainstorm.${snapshotSummary}`);
92
156
  return 0;
93
157
  }
94
158
  await uninstallCclaw(ctx.cwd);
@@ -94,10 +94,10 @@ export const CCLAW_AGENTS = [
94
94
  },
95
95
  {
96
96
  name: "security-reviewer",
97
- description: "PROACTIVE after auth, crypto, secrets, parsers, or sensitive data paths change. MUST BE USED when trust boundaries move, new external inputs arrive, or LLM/tool output influences privileged actions.",
97
+ description: "MANDATORY during every review stage. Even when no auth, crypto, secrets, parsers, or sensitive data paths changed, produce an explicit 'no-change' security attestation. MUST BE USED when trust boundaries move, new external inputs arrive, or LLM/tool output influences privileged actions.",
98
98
  tools: ["Read", "Grep", "Glob"],
99
99
  model: "balanced",
100
- activation: "proactive",
100
+ activation: "mandatory",
101
101
  relatedStages: ["review", "design"],
102
102
  body: [
103
103
  "You are a **security vulnerability detection** specialist focused on practical exploitability.",
@@ -216,7 +216,7 @@ export function agentRoutingTable() {
216
216
  | Brainstorm (start with \`/cc <idea>\`) | planner | — |
217
217
  | Scope / Design / Spec / Plan (advance via \`/cc-next\`) | planner | security-reviewer on design, spec-reviewer on spec |
218
218
  | TDD (via \`/cc-next\`) | test-author | doc-updater |
219
- | Review (via \`/cc-next\`) | spec-reviewer, code-reviewer | security-reviewer |
219
+ | Review (via \`/cc-next\`) | spec-reviewer, code-reviewer, security-reviewer | — |
220
220
  | Ship (via \`/cc-next\`) | — | doc-updater |
221
221
  `;
222
222
  }
@@ -231,8 +231,8 @@ Cclaw provides specialist agents under \`.cclaw/agents/\` for targeted delegatio
231
231
  ${agentRoutingTable()}
232
232
 
233
233
  **Activation modes:**
234
- - **Mandatory:** MUST be used when the related stage runs (spec-reviewer, code-reviewer during review)
235
- - **Proactive:** Should be used automatically when context matches (planner for complex features, security-reviewer for auth code)
234
+ - **Mandatory:** MUST be used when the related stage runs (spec-reviewer, code-reviewer, and security-reviewer during review; planner during scope and design; test-author during tdd; doc-updater during ship). Even if a change has no trust-boundary impact, security-reviewer produces an explicit no-change attestation.
235
+ - **Proactive:** Should be used automatically when context matches (planner for complex features, security-reviewer escalations outside review, doc-updater on behavior changes)
236
236
  - **On-demand:** Invoked only when explicitly requested
237
237
 
238
238
  **Agent files:** \`.cclaw/agents/{name}.md\` — each contains YAML frontmatter with tools and model tier.