cclaw-cli 0.48.28 → 0.48.30

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.
@@ -1,10 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { resolveArtifactPath as resolveStageArtifactPath } from "./artifact-paths.js";
3
4
  import { RUNTIME_ROOT, SHIP_FINALIZATION_MODES } from "./constants.js";
4
5
  import { exists } from "./fs-utils.js";
5
6
  import { stageSchema } from "./content/stage-schema.js";
6
7
  import { FLOW_STAGES } from "./types.js";
7
- async function resolveArtifactPath(projectRoot, fileName) {
8
+ async function resolveNamedArtifactPath(projectRoot, fileName) {
8
9
  const relPath = path.join(RUNTIME_ROOT, "artifacts", fileName);
9
10
  const absPath = path.join(projectRoot, relPath);
10
11
  return { absPath, relPath };
@@ -791,7 +792,11 @@ function validateSectionBody(sectionBody, rule, sectionName) {
791
792
  }
792
793
  export async function lintArtifact(projectRoot, stage, track = "standard") {
793
794
  const schema = stageSchema(stage, track);
794
- const { absPath: absFile, relPath: relFile } = await resolveArtifactPath(projectRoot, schema.artifactFile);
795
+ const { absPath: absFile, relPath: relFile } = await resolveStageArtifactPath(stage, {
796
+ projectRoot,
797
+ track,
798
+ intent: "read"
799
+ });
795
800
  const findings = [];
796
801
  if (!(await exists(absFile))) {
797
802
  for (const v of schema.artifactValidation) {
@@ -949,8 +954,14 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
949
954
  ? "No placeholder tokens detected in Task List."
950
955
  : `Detected placeholder token(s) in Task List: ${placeholderHits.join(", ")}.`
951
956
  });
952
- const scopePath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "02-scope.md");
953
- const scopeRaw = (await exists(scopePath)) ? await fs.readFile(scopePath, "utf8") : "";
957
+ const scopeArtifact = await resolveStageArtifactPath("scope", {
958
+ projectRoot,
959
+ track,
960
+ intent: "read"
961
+ });
962
+ const scopeRaw = (await exists(scopeArtifact.absPath))
963
+ ? await fs.readFile(scopeArtifact.absPath, "utf8")
964
+ : "";
954
965
  const scopeDecisionIds = extractDecisionIds(scopeRaw);
955
966
  const missingDecisionRefs = scopeDecisionIds.filter((id) => !raw.includes(id));
956
967
  findings.push({
@@ -1053,7 +1064,7 @@ function isStringArray(v) {
1053
1064
  }
1054
1065
  export async function validateReviewArmy(projectRoot) {
1055
1066
  const errors = [];
1056
- const { absPath, relPath } = await resolveArtifactPath(projectRoot, "07-review-army.json");
1067
+ const { absPath, relPath } = await resolveNamedArtifactPath(projectRoot, "07-review-army.json");
1057
1068
  if (!(await exists(absPath))) {
1058
1069
  return { valid: false, errors: [`Missing file: ${relPath}`] };
1059
1070
  }
@@ -0,0 +1,28 @@
1
+ import type { FlowStage, FlowTrack } from "./types.js";
2
+ export type ArtifactPathIntent = "read" | "write";
3
+ export interface ResolveArtifactPathContext {
4
+ projectRoot: string;
5
+ track?: FlowTrack;
6
+ /**
7
+ * Optional brainstorm topic used for `<slug>` interpolation.
8
+ * When omitted, the resolver attempts to infer it from `00-idea.md`.
9
+ */
10
+ topic?: string;
11
+ /**
12
+ * - read: locate an existing artifact first (new slug shape, then legacy fallback).
13
+ * - write: return a non-colliding writable path for a new artifact.
14
+ */
15
+ intent?: ArtifactPathIntent;
16
+ }
17
+ export interface ResolvedArtifactPath {
18
+ stage: FlowStage;
19
+ fileName: string;
20
+ relPath: string;
21
+ absPath: string;
22
+ source: "existing" | "generated";
23
+ legacy: boolean;
24
+ }
25
+ export declare function isSlugArtifactPattern(filePattern: string): boolean;
26
+ export declare function legacyArtifactFileName(filePattern: string): string;
27
+ export declare function slugifyArtifactTopic(topic: string): string;
28
+ export declare function resolveArtifactPath(stage: FlowStage, context: ResolveArtifactPathContext): Promise<ResolvedArtifactPath>;
@@ -0,0 +1,261 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stageSchema } from "./content/stage-schema.js";
4
+ import { RUNTIME_ROOT } from "./constants.js";
5
+ import { exists } from "./fs-utils.js";
6
+ const LEGACY_ARTIFACT_GRACE_CYCLES = 2;
7
+ const DEFAULT_TOPIC_SLUG = "topic";
8
+ function escapeRegExp(value) {
9
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
10
+ }
11
+ function splitExt(fileName) {
12
+ const ext = path.extname(fileName);
13
+ if (!ext) {
14
+ return { stem: fileName, ext: "" };
15
+ }
16
+ return { stem: fileName.slice(0, -ext.length), ext };
17
+ }
18
+ function appendCollisionSuffix(fileName, index) {
19
+ const { stem, ext } = splitExt(fileName);
20
+ return `${stem}-${index}${ext}`;
21
+ }
22
+ export function isSlugArtifactPattern(filePattern) {
23
+ return filePattern.includes("<slug>");
24
+ }
25
+ export function legacyArtifactFileName(filePattern) {
26
+ if (!isSlugArtifactPattern(filePattern)) {
27
+ return filePattern;
28
+ }
29
+ return filePattern.replace(/-<slug>/gu, "");
30
+ }
31
+ export function slugifyArtifactTopic(topic) {
32
+ const normalized = topic
33
+ .toLowerCase()
34
+ .trim()
35
+ .replace(/[`"'“”‘’()[\]{}<>]/gu, " ")
36
+ .replace(/[^a-z0-9]+/gu, "-")
37
+ .replace(/^-+/u, "")
38
+ .replace(/-+$/u, "");
39
+ if (normalized.length === 0) {
40
+ return DEFAULT_TOPIC_SLUG;
41
+ }
42
+ return normalized.slice(0, 48);
43
+ }
44
+ function slugPatternRegex(filePattern) {
45
+ const [left, right] = filePattern.split("<slug>");
46
+ return new RegExp(`^${escapeRegExp(left ?? "")}[a-z0-9]+(?:-[a-z0-9]+)*(?:-\\d+)?${escapeRegExp(right ?? "")}$`, "u");
47
+ }
48
+ function searchRoots(projectRoot) {
49
+ return [
50
+ {
51
+ absDir: path.join(projectRoot, RUNTIME_ROOT, "artifacts"),
52
+ relPrefix: path.join(RUNTIME_ROOT, "artifacts")
53
+ },
54
+ {
55
+ absDir: projectRoot,
56
+ relPrefix: ""
57
+ }
58
+ ];
59
+ }
60
+ function candidateFromRoot(root, fileName) {
61
+ return {
62
+ relPath: root.relPrefix ? path.join(root.relPrefix, fileName) : fileName,
63
+ absPath: path.join(root.absDir, fileName)
64
+ };
65
+ }
66
+ async function inferTopicFromIdeaArtifact(projectRoot) {
67
+ const ideaPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "00-idea.md");
68
+ if (!(await exists(ideaPath))) {
69
+ return null;
70
+ }
71
+ try {
72
+ const raw = await fs.readFile(ideaPath, "utf8");
73
+ const lines = raw.split(/\r?\n/gu);
74
+ const userPromptHeading = lines.findIndex((line) => /^##\s+user prompt\b/iu.test(line.trim()));
75
+ if (userPromptHeading >= 0) {
76
+ for (let i = userPromptHeading + 1; i < lines.length; i += 1) {
77
+ const line = lines[i].trim();
78
+ if (line.length === 0)
79
+ continue;
80
+ if (/^##\s+/u.test(line))
81
+ break;
82
+ const candidate = line.replace(/^[-*>\s#]+/u, "").trim();
83
+ if (candidate.length > 0) {
84
+ return candidate;
85
+ }
86
+ }
87
+ }
88
+ const metadataLine = /^(?:class|track|stack|reclassification)\s*:/iu;
89
+ for (const line of lines) {
90
+ const trimmed = line.trim();
91
+ if (trimmed.length === 0)
92
+ continue;
93
+ if (metadataLine.test(trimmed))
94
+ continue;
95
+ if (/^##\s+/u.test(trimmed))
96
+ continue;
97
+ const candidate = trimmed.replace(/^[-*>\s#]+/u, "").trim();
98
+ if (candidate.length > 0) {
99
+ return candidate;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ async function resolvedTopicSlug(projectRoot, stage, explicitTopic) {
109
+ if (explicitTopic && explicitTopic.trim().length > 0) {
110
+ return slugifyArtifactTopic(explicitTopic);
111
+ }
112
+ const inferred = await inferTopicFromIdeaArtifact(projectRoot);
113
+ if (inferred && inferred.trim().length > 0) {
114
+ return slugifyArtifactTopic(inferred);
115
+ }
116
+ return slugifyArtifactTopic(stage);
117
+ }
118
+ async function collectExistingCandidates(projectRoot, filePattern, legacyFile) {
119
+ const roots = searchRoots(projectRoot);
120
+ const candidates = [];
121
+ const hasSlugPattern = isSlugArtifactPattern(filePattern);
122
+ const matcher = hasSlugPattern ? slugPatternRegex(filePattern) : null;
123
+ for (const root of roots) {
124
+ if (hasSlugPattern && matcher) {
125
+ let entries = [];
126
+ try {
127
+ entries = await fs.readdir(root.absDir);
128
+ }
129
+ catch {
130
+ entries = [];
131
+ }
132
+ for (const entry of entries) {
133
+ if (!matcher.test(entry))
134
+ continue;
135
+ const { relPath, absPath } = candidateFromRoot(root, entry);
136
+ let mtimeMs = 0;
137
+ try {
138
+ const stat = await fs.stat(absPath);
139
+ mtimeMs = stat.mtimeMs;
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ candidates.push({
145
+ fileName: entry,
146
+ relPath,
147
+ absPath,
148
+ mtimeMs,
149
+ legacy: false
150
+ });
151
+ }
152
+ }
153
+ else {
154
+ const { relPath, absPath } = candidateFromRoot(root, filePattern);
155
+ if (await exists(absPath)) {
156
+ let mtimeMs = 0;
157
+ try {
158
+ const stat = await fs.stat(absPath);
159
+ mtimeMs = stat.mtimeMs;
160
+ }
161
+ catch {
162
+ mtimeMs = 0;
163
+ }
164
+ candidates.push({
165
+ fileName: filePattern,
166
+ relPath,
167
+ absPath,
168
+ mtimeMs,
169
+ legacy: false
170
+ });
171
+ }
172
+ }
173
+ if (legacyFile && LEGACY_ARTIFACT_GRACE_CYCLES > 0) {
174
+ const { relPath, absPath } = candidateFromRoot(root, legacyFile);
175
+ if (await exists(absPath)) {
176
+ let mtimeMs = 0;
177
+ try {
178
+ const stat = await fs.stat(absPath);
179
+ mtimeMs = stat.mtimeMs;
180
+ }
181
+ catch {
182
+ mtimeMs = 0;
183
+ }
184
+ candidates.push({
185
+ fileName: legacyFile,
186
+ relPath,
187
+ absPath,
188
+ mtimeMs,
189
+ legacy: true
190
+ });
191
+ }
192
+ }
193
+ }
194
+ candidates.sort((a, b) => {
195
+ if (b.mtimeMs !== a.mtimeMs) {
196
+ return b.mtimeMs - a.mtimeMs;
197
+ }
198
+ if (a.legacy !== b.legacy) {
199
+ return a.legacy ? 1 : -1;
200
+ }
201
+ return a.fileName.localeCompare(b.fileName);
202
+ });
203
+ return candidates;
204
+ }
205
+ export async function resolveArtifactPath(stage, context) {
206
+ const track = context.track ?? "standard";
207
+ const intent = context.intent ?? "read";
208
+ const filePattern = stageSchema(stage, track).artifactFile;
209
+ const hasSlugPattern = isSlugArtifactPattern(filePattern);
210
+ const legacyFile = hasSlugPattern ? legacyArtifactFileName(filePattern) : null;
211
+ const existing = await collectExistingCandidates(context.projectRoot, filePattern, legacyFile);
212
+ if (intent === "read" && existing.length > 0) {
213
+ const picked = existing[0];
214
+ return {
215
+ stage,
216
+ fileName: picked.fileName,
217
+ relPath: picked.relPath,
218
+ absPath: picked.absPath,
219
+ source: "existing",
220
+ legacy: picked.legacy
221
+ };
222
+ }
223
+ const artifactRoot = path.join(context.projectRoot, RUNTIME_ROOT, "artifacts");
224
+ if (!hasSlugPattern) {
225
+ return {
226
+ stage,
227
+ fileName: filePattern,
228
+ relPath: path.join(RUNTIME_ROOT, "artifacts", filePattern),
229
+ absPath: path.join(artifactRoot, filePattern),
230
+ source: "generated",
231
+ legacy: false
232
+ };
233
+ }
234
+ const topicSlug = await resolvedTopicSlug(context.projectRoot, stage, context.topic);
235
+ const baseFileName = filePattern.replace("<slug>", topicSlug);
236
+ if (intent === "read") {
237
+ return {
238
+ stage,
239
+ fileName: baseFileName,
240
+ relPath: path.join(RUNTIME_ROOT, "artifacts", baseFileName),
241
+ absPath: path.join(artifactRoot, baseFileName),
242
+ source: "generated",
243
+ legacy: false
244
+ };
245
+ }
246
+ let candidate = baseFileName;
247
+ let index = 2;
248
+ // Keep incrementing while a matching file exists under active artifacts root.
249
+ while (await exists(path.join(artifactRoot, candidate))) {
250
+ candidate = appendCollisionSuffix(baseFileName, index);
251
+ index += 1;
252
+ }
253
+ return {
254
+ stage,
255
+ fileName: candidate,
256
+ relPath: path.join(RUNTIME_ROOT, "artifacts", candidate),
257
+ absPath: path.join(artifactRoot, candidate),
258
+ source: "generated",
259
+ legacy: false
260
+ };
261
+ }
@@ -67,17 +67,32 @@ export function parseSkillEnvelope(raw) {
67
67
  }
68
68
  return parsed;
69
69
  }
70
- const ARTIFACT_STAGE_BY_PATH = {
71
- ".cclaw/artifacts/01-brainstorm.md": "brainstorm",
72
- ".cclaw/artifacts/02-scope.md": "scope",
73
- ".cclaw/artifacts/02a-research.md": "design",
74
- ".cclaw/artifacts/03-design.md": "design",
75
- ".cclaw/artifacts/04-spec.md": "spec",
76
- ".cclaw/artifacts/05-plan.md": "plan",
77
- ".cclaw/artifacts/06-tdd.md": "tdd",
78
- ".cclaw/artifacts/07-review.md": "review",
79
- ".cclaw/artifacts/08-ship.md": "ship"
70
+ const ARTIFACT_STAGE_BY_PREFIX = {
71
+ "01": "brainstorm",
72
+ "02": "scope",
73
+ "03": "design",
74
+ "04": "spec",
75
+ "05": "plan",
76
+ "06": "tdd",
77
+ "07": "review",
78
+ "08": "ship"
80
79
  };
80
+ const ARTIFACT_STAGE_BY_SPECIAL_FILE = {
81
+ "02a-research.md": "design"
82
+ };
83
+ function stageFromArtifactPath(artifactPath) {
84
+ const normalized = artifactPath.replace(/\\/gu, "/");
85
+ const fileName = normalized.split("/").pop() ?? normalized;
86
+ const special = ARTIFACT_STAGE_BY_SPECIAL_FILE[fileName];
87
+ if (special) {
88
+ return special;
89
+ }
90
+ const match = /^(\d{2})(?:[a-z])?-/u.exec(fileName);
91
+ if (!match) {
92
+ return null;
93
+ }
94
+ return ARTIFACT_STAGE_BY_PREFIX[match[1]] ?? null;
95
+ }
81
96
  const REQUIRED_GATE_IDS = {
82
97
  brainstorm: [
83
98
  "brainstorm_approaches_compared",
@@ -172,7 +187,7 @@ function tieredArtifactValidation(stage, rows) {
172
187
  function readsFromForTrack(readsFrom, track) {
173
188
  const stageSet = new Set(TRACK_STAGES[track]);
174
189
  return readsFrom.filter((artifactPath) => {
175
- const stage = ARTIFACT_STAGE_BY_PATH[artifactPath];
190
+ const stage = stageFromArtifactPath(artifactPath);
176
191
  if (!stage) {
177
192
  return true;
178
193
  }
@@ -46,7 +46,7 @@ export const BRAINSTORM = {
46
46
  "**Recommend only after reaction** — present final recommendation with rationale that explicitly references user feedback.",
47
47
  "**Present design by sections** — scale each section to its complexity. Ask after each section whether it looks right so far. Cover: architecture, key components, data flow.",
48
48
  "**Optional visual companion** — when architecture/data flow complexity is medium+ offer a compact diagram (ASCII or Mermaid) before artifact write-up.",
49
- "**Write artifact** to `.cclaw/artifacts/01-brainstorm.md`.",
49
+ "**Write artifact** to `.cclaw/artifacts/01-brainstorm-<slug>.md`.",
50
50
  "**Document-quality pass** — run a brief adversarial review of the artifact (gaps, contradictions, missing trade-offs), then patch before user review.",
51
51
  "**Self-review** — scan for placeholders/TODOs, check internal consistency, verify scope is focused, resolve any ambiguity.",
52
52
  "**User reviews artifact** — ask the user to review the written artifact and explicitly approve or request changes.",
@@ -75,7 +75,7 @@ export const BRAINSTORM = {
75
75
  "Collect user reaction before giving your recommendation.",
76
76
  "Recommend after reaction and explain how feedback changed the recommendation.",
77
77
  "Present design sections incrementally, get approval after each.",
78
- "Write approved direction to `.cclaw/artifacts/01-brainstorm.md`.",
78
+ "Write approved direction to `.cclaw/artifacts/01-brainstorm-<slug>.md`.",
79
79
  "Run document-quality pass to close contradictions and weak trade-off reasoning.",
80
80
  "Self-review: placeholder scan, internal consistency, scope check, ambiguity check.",
81
81
  "Request explicit user approval of the artifact.",
@@ -87,7 +87,7 @@ export const BRAINSTORM = {
87
87
  { id: "brainstorm_artifact_reviewed", description: "User reviewed the written brainstorm artifact and confirmed readiness." }
88
88
  ],
89
89
  requiredEvidence: [
90
- "Artifact written to `.cclaw/artifacts/01-brainstorm.md`.",
90
+ "Artifact written to `.cclaw/artifacts/01-brainstorm-<slug>.md`.",
91
91
  "Project context was explored (files, docs, or recent activity referenced).",
92
92
  "Clarifying questions and their answers are captured.",
93
93
  "2-3 approaches with trade-offs are recorded, including one higher-upside challenger option.",
@@ -119,11 +119,11 @@ export const BRAINSTORM = {
119
119
  ]
120
120
  },
121
121
  artifactRules: {
122
- artifactFile: "01-brainstorm.md",
122
+ artifactFile: "01-brainstorm-<slug>.md",
123
123
  completionStatus: ["DONE", "DONE_WITH_CONCERNS", "BLOCKED"],
124
124
  crossStageTrace: {
125
125
  readsFrom: [],
126
- writesTo: [".cclaw/artifacts/01-brainstorm.md"],
126
+ writesTo: [".cclaw/artifacts/01-brainstorm-<slug>.md"],
127
127
  traceabilityRule: "Scope and design decisions must trace back to explored context and approved brainstorm direction."
128
128
  },
129
129
  artifactValidation: [
@@ -100,7 +100,7 @@ export const DESIGN = {
100
100
  ],
101
101
  requiredEvidence: [
102
102
  "Research artifact written to `.cclaw/artifacts/02a-research.md` with stack/features/architecture/pitfalls sections plus synthesis.",
103
- "Artifact written to `.cclaw/artifacts/03-design.md`.",
103
+ "Artifact written to `.cclaw/artifacts/03-design-<slug>.md`.",
104
104
  "Failure-mode table exists in Method/Exception/Rescue/UserSees format.",
105
105
  "Data-flow shadow and error-flow diagrams are present for Standard+ complexity.",
106
106
  "Security & threat model findings are documented with mitigations.",
@@ -138,15 +138,15 @@ export const DESIGN = {
138
138
  ]
139
139
  },
140
140
  artifactRules: {
141
- artifactFile: "03-design.md",
141
+ artifactFile: "03-design-<slug>.md",
142
142
  completionStatus: ["DONE", "DONE_WITH_CONCERNS", "BLOCKED"],
143
143
  crossStageTrace: {
144
144
  readsFrom: [
145
- ".cclaw/artifacts/01-brainstorm.md",
146
- ".cclaw/artifacts/02-scope.md",
145
+ ".cclaw/artifacts/01-brainstorm-<slug>.md",
146
+ ".cclaw/artifacts/02-scope-<slug>.md",
147
147
  ".cclaw/artifacts/02a-research.md"
148
148
  ],
149
- writesTo: [".cclaw/artifacts/03-design.md"],
149
+ writesTo: [".cclaw/artifacts/03-design-<slug>.md"],
150
150
  traceabilityRule: "Every architecture decision must trace to a scope boundary. Every downstream spec requirement must trace to a design decision."
151
151
  },
152
152
  artifactValidation: [