cclaw-cli 0.48.30 → 0.48.32

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.
@@ -19,6 +19,11 @@ import { runEnvelopeValidateCommand } from "./envelope-validate.js";
19
19
  import { runKnowledgeDigestCommand } from "./knowledge-digest.js";
20
20
  import { runTddLoopStatusCommand } from "./tdd-loop-status.js";
21
21
  import { runTddRedEvidenceCommand } from "./tdd-red-evidence.js";
22
+ import { extractReviewLoopEnvelopeFromArtifact } from "../content/review-loop.js";
23
+ const AUTO_REVIEW_LOOP_GATE_BY_STAGE = {
24
+ scope: "scope_user_approved",
25
+ design: "design_architecture_locked"
26
+ };
22
27
  function unique(values) {
23
28
  return [...new Set(values)];
24
29
  }
@@ -55,6 +60,111 @@ const SHA_WITH_LABEL_PATTERN = /\b(?:sha|commit)(?:\s*[:=]|\s+)\s*[0-9a-f]{7,40}
55
60
  const PASS_STATUS_PATTERN = /\b(?:pass|passed|green|ok)\b/iu;
56
61
  const SHIP_FINALIZATION_MODE_PATTERN = new RegExp(`\\b(?:${SHIP_FINALIZATION_MODES.join("|")})\\b`, "u");
57
62
  const SHIP_FINALIZATION_MODE_HINT = SHIP_FINALIZATION_MODES.join(", ");
63
+ const REVIEW_LOOP_STOP_REASONS = new Set([
64
+ "quality_threshold_met",
65
+ "max_iterations_reached",
66
+ "user_opt_out"
67
+ ]);
68
+ function asRecord(value) {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ return null;
71
+ }
72
+ return value;
73
+ }
74
+ function pickReviewLoopEnvelope(value) {
75
+ const direct = asRecord(value);
76
+ if (!direct)
77
+ return null;
78
+ if (direct.type === "review-loop")
79
+ return direct;
80
+ const payload = asRecord(direct.payload);
81
+ if (payload?.type === "review-loop")
82
+ return payload;
83
+ const nested = asRecord(direct.reviewLoop);
84
+ if (nested?.type === "review-loop")
85
+ return nested;
86
+ return null;
87
+ }
88
+ function validateReviewLoopGateEvidence(stage, evidence) {
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(evidence);
92
+ }
93
+ catch {
94
+ return "must be JSON containing a review-loop envelope (`type: \"review-loop\"`) in top-level, `payload`, or `reviewLoop`.";
95
+ }
96
+ const envelope = pickReviewLoopEnvelope(parsed);
97
+ if (!envelope) {
98
+ return "must include a review-loop envelope (`type: \"review-loop\"`) in top-level, `payload`, or `reviewLoop`.";
99
+ }
100
+ if (envelope.stage !== stage) {
101
+ return `review-loop envelope stage must be "${stage}".`;
102
+ }
103
+ const targetScore = envelope.targetScore;
104
+ if (typeof targetScore !== "number" || Number.isNaN(targetScore) || targetScore < 0 || targetScore > 1) {
105
+ return "review-loop targetScore must be a number between 0 and 1.";
106
+ }
107
+ const maxIterations = envelope.maxIterations;
108
+ if (typeof maxIterations !== "number" ||
109
+ Number.isNaN(maxIterations) ||
110
+ !Number.isInteger(maxIterations) ||
111
+ maxIterations < 1) {
112
+ return "review-loop maxIterations must be an integer >= 1.";
113
+ }
114
+ if (typeof envelope.stopReason !== "string" || !REVIEW_LOOP_STOP_REASONS.has(envelope.stopReason)) {
115
+ return "review-loop stopReason must be one of quality_threshold_met, max_iterations_reached, user_opt_out.";
116
+ }
117
+ const rows = envelope.iterations;
118
+ if (!Array.isArray(rows) || rows.length === 0) {
119
+ return "review-loop iterations must be a non-empty array.";
120
+ }
121
+ if (rows.length > maxIterations) {
122
+ return "review-loop iterations count cannot exceed maxIterations.";
123
+ }
124
+ let prevScore = -Infinity;
125
+ let reachedTarget = false;
126
+ for (let index = 0; index < rows.length; index++) {
127
+ const row = asRecord(rows[index]);
128
+ if (!row) {
129
+ return `review-loop iterations[${index}] must be an object.`;
130
+ }
131
+ const iteration = row.iteration;
132
+ const qualityScore = row.qualityScore;
133
+ const findingsCount = row.findingsCount;
134
+ if (typeof iteration !== "number" ||
135
+ Number.isNaN(iteration) ||
136
+ !Number.isInteger(iteration) ||
137
+ iteration < 1) {
138
+ return `review-loop iterations[${index}].iteration must be an integer >= 1.`;
139
+ }
140
+ if (typeof qualityScore !== "number" ||
141
+ Number.isNaN(qualityScore) ||
142
+ qualityScore < 0 ||
143
+ qualityScore > 1) {
144
+ return `review-loop iterations[${index}].qualityScore must be between 0 and 1.`;
145
+ }
146
+ if (typeof findingsCount !== "number" ||
147
+ Number.isNaN(findingsCount) ||
148
+ !Number.isInteger(findingsCount) ||
149
+ findingsCount < 0) {
150
+ return `review-loop iterations[${index}].findingsCount must be an integer >= 0.`;
151
+ }
152
+ if (qualityScore + Number.EPSILON < prevScore) {
153
+ return "review-loop qualityScore must be monotonic non-decreasing across iterations.";
154
+ }
155
+ if (qualityScore >= targetScore) {
156
+ reachedTarget = true;
157
+ }
158
+ prevScore = qualityScore;
159
+ }
160
+ if (envelope.stopReason === "quality_threshold_met" && !reachedTarget) {
161
+ return "review-loop stopReason is quality_threshold_met but no iteration reached targetScore.";
162
+ }
163
+ if (envelope.stopReason === "max_iterations_reached" && rows.length < maxIterations) {
164
+ return "review-loop stopReason is max_iterations_reached but iterations are below maxIterations.";
165
+ }
166
+ return null;
167
+ }
58
168
  // Per-gate validators keyed by `${stage}:${gateId}`. Returning a non-null
59
169
  // string surfaces the reason as an `advance-stage` failure so evidence is
60
170
  // guaranteed to carry the structural breadcrumbs downstream tooling
@@ -77,7 +187,9 @@ const GATE_EVIDENCE_VALIDATORS = {
77
187
  return `must name the finalization mode that ran (for example ${SHIP_FINALIZATION_MODE_HINT}).`;
78
188
  }
79
189
  return null;
80
- }
190
+ },
191
+ "scope:scope_user_approved": (evidence) => validateReviewLoopGateEvidence("scope", evidence),
192
+ "design:design_architecture_locked": (evidence) => validateReviewLoopGateEvidence("design", evidence)
81
193
  };
82
194
  function validateGateEvidenceShape(stage, gateId, evidence) {
83
195
  const validator = GATE_EVIDENCE_VALIDATORS[`${stage}:${gateId}`];
@@ -232,6 +344,35 @@ function parseCsv(raw) {
232
344
  .map((item) => item.trim())
233
345
  .filter((item) => item.length > 0);
234
346
  }
347
+ async function hydrateReviewLoopEvidenceFromArtifact(projectRoot, stage, track, selectedGateIds, evidenceByGate) {
348
+ const gateId = AUTO_REVIEW_LOOP_GATE_BY_STAGE[stage];
349
+ if (!gateId)
350
+ return;
351
+ if (!selectedGateIds.includes(gateId))
352
+ return;
353
+ const existing = evidenceByGate[gateId];
354
+ if (typeof existing === "string" && existing.trim().length > 0)
355
+ return;
356
+ const resolved = await resolveArtifactPath(stage, {
357
+ projectRoot,
358
+ track,
359
+ intent: "read"
360
+ });
361
+ let raw = "";
362
+ try {
363
+ raw = await fs.readFile(resolved.absPath, "utf8");
364
+ }
365
+ catch {
366
+ return;
367
+ }
368
+ const reviewStage = stage === "scope" || stage === "design" ? stage : null;
369
+ if (!reviewStage)
370
+ return;
371
+ const envelope = extractReviewLoopEnvelopeFromArtifact(raw, reviewStage, resolved.relPath);
372
+ if (!envelope)
373
+ return;
374
+ evidenceByGate[gateId] = JSON.stringify(envelope);
375
+ }
235
376
  function parseAdvanceStageArgs(tokens) {
236
377
  const [stageRaw, ...flagTokens] = tokens;
237
378
  if (!isFlowStageValue(stageRaw)) {
@@ -488,6 +629,7 @@ async function runAdvanceStage(projectRoot, args, io) {
488
629
  });
489
630
  }
490
631
  }
632
+ await hydrateReviewLoopEvidenceFromArtifact(projectRoot, args.stage, flowState.track, selectedGateIds, args.evidenceByGate);
491
633
  const catalog = flowState.stageGateCatalog[args.stage];
492
634
  const nextPassed = unique([...catalog.passed, ...selectedGateIds]).filter((gateId) => allowedGateIds.has(gateId));
493
635
  const nextPassedSet = new Set(nextPassed);
@@ -4,10 +4,24 @@ export interface TraceEntry {
4
4
  testSlices: string[];
5
5
  reviewFindings: string[];
6
6
  }
7
+ export interface ReviewLoopTraceEntry {
8
+ stage: "scope" | "design";
9
+ artifactPath: string;
10
+ targetScore: number;
11
+ maxIterations: number;
12
+ stopReason: "quality_threshold_met" | "max_iterations_reached" | "user_opt_out";
13
+ finalScore: number;
14
+ iterations: Array<{
15
+ iteration: number;
16
+ qualityScore: number;
17
+ findingsCount: number;
18
+ }>;
19
+ }
7
20
  export interface TraceMatrix {
8
21
  entries: TraceEntry[];
9
22
  orphanedCriteria: string[];
10
23
  orphanedTasks: string[];
11
24
  orphanedTests: string[];
25
+ reviewLoops: ReviewLoopTraceEntry[];
12
26
  }
13
27
  export declare function buildTraceMatrix(projectRoot: string): Promise<TraceMatrix>;
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { resolveArtifactPath } from "./artifact-paths.js";
3
4
  import { RUNTIME_ROOT } from "./constants.js";
5
+ import { extractReviewLoopEnvelopeFromArtifact } from "./content/review-loop.js";
4
6
  import { exists } from "./fs-utils.js";
5
7
  function activeArtifactPath(projectRoot, name) {
6
8
  return path.join(projectRoot, RUNTIME_ROOT, "artifacts", name);
@@ -110,6 +112,56 @@ function layer1LinesForCriterion(layer1, criterionId) {
110
112
  }
111
113
  return out;
112
114
  }
115
+ async function readStageArtifact(projectRoot, stage) {
116
+ let resolved = null;
117
+ try {
118
+ resolved = await resolveArtifactPath(stage, {
119
+ projectRoot,
120
+ intent: "read"
121
+ });
122
+ }
123
+ catch {
124
+ resolved = null;
125
+ }
126
+ if (!resolved || !(await exists(resolved.absPath))) {
127
+ return null;
128
+ }
129
+ try {
130
+ const markdown = await fs.readFile(resolved.absPath, "utf8");
131
+ return { markdown, relPath: resolved.relPath };
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ async function collectReviewLoopTraceEntries(projectRoot) {
138
+ const entries = [];
139
+ for (const stage of ["scope", "design"]) {
140
+ const artifact = await readStageArtifact(projectRoot, stage);
141
+ if (!artifact)
142
+ continue;
143
+ const envelope = extractReviewLoopEnvelopeFromArtifact(artifact.markdown, stage, artifact.relPath);
144
+ if (!envelope)
145
+ continue;
146
+ const finalScore = envelope.iterations.length > 0
147
+ ? envelope.iterations[envelope.iterations.length - 1].qualityScore
148
+ : 0;
149
+ entries.push({
150
+ stage,
151
+ artifactPath: artifact.relPath,
152
+ targetScore: envelope.targetScore,
153
+ maxIterations: envelope.maxIterations,
154
+ stopReason: envelope.stopReason,
155
+ finalScore,
156
+ iterations: envelope.iterations.map((row) => ({
157
+ iteration: row.iteration,
158
+ qualityScore: row.qualityScore,
159
+ findingsCount: row.findingsCount
160
+ }))
161
+ });
162
+ }
163
+ return entries;
164
+ }
113
165
  export async function buildTraceMatrix(projectRoot) {
114
166
  const spec = await readArtifact(projectRoot, "04-spec.md");
115
167
  const plan = await readArtifact(projectRoot, "05-plan.md");
@@ -163,10 +215,12 @@ export async function buildTraceMatrix(projectRoot) {
163
215
  return !acs || acs.length === 0;
164
216
  });
165
217
  });
218
+ const reviewLoops = await collectReviewLoopTraceEntries(projectRoot);
166
219
  return {
167
220
  entries,
168
221
  orphanedCriteria,
169
222
  orphanedTasks,
170
- orphanedTests
223
+ orphanedTests,
224
+ reviewLoops
171
225
  };
172
226
  }
package/dist/types.d.ts CHANGED
@@ -118,6 +118,29 @@ export interface IronLawsConfig {
118
118
  */
119
119
  strictLaws?: string[];
120
120
  }
121
+ /**
122
+ * Optional opt-in audit toggles for additional stage lint gates.
123
+ *
124
+ * Disabled by default so existing projects are not forced into stricter
125
+ * checks until they explicitly enable them in config.
126
+ */
127
+ export interface OptInAuditsConfig {
128
+ /** When true, scope lint requires a filled `Pre-Scope System Audit` section. */
129
+ scopePreAudit?: boolean;
130
+ /** When true, design lint runs stale diagram drift checks against blast radius files. */
131
+ staleDiagramAudit?: boolean;
132
+ }
133
+ export interface ReviewLoopExternalSecondOpinionConfig {
134
+ /** Enables a second outside-voice pass for review-loop iterations. */
135
+ enabled?: boolean;
136
+ /** Optional model label for traceability in artifacts/logs. */
137
+ model?: string;
138
+ /** Minimum score delta that should be surfaced as disagreement context. */
139
+ scoreDeltaThreshold?: number;
140
+ }
141
+ export interface ReviewLoopConfig {
142
+ externalSecondOpinion?: ReviewLoopExternalSecondOpinionConfig;
143
+ }
121
144
  export interface CclawConfig {
122
145
  version: string;
123
146
  flowVersion: string;
@@ -170,6 +193,10 @@ export interface CclawConfig {
170
193
  sliceReview?: SliceReviewConfig;
171
194
  /** Optional per-law strictness controls for hook-enforced iron laws. */
172
195
  ironLaws?: IronLawsConfig;
196
+ /** Optional opt-in audit gates for scope/design stages. */
197
+ optInAudits?: OptInAuditsConfig;
198
+ /** Optional runtime knobs for outside-voice review loops. */
199
+ reviewLoop?: ReviewLoopConfig;
173
200
  }
174
201
  /**
175
202
  * @deprecated Use `CclawConfig` instead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.30",
3
+ "version": "0.48.32",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {