cclaw-cli 0.44.0 → 0.46.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 +1 -1
- package/dist/artifact-linter.d.ts +31 -1
- package/dist/artifact-linter.js +312 -9
- package/dist/content/learnings.js +20 -0
- package/dist/content/skills.js +1 -1
- package/dist/content/stage-common-guidance.js +11 -4
- package/dist/content/stages/design.js +2 -2
- package/dist/content/templates.js +27 -0
- package/dist/internal/advance-stage.js +101 -1
- package/dist/knowledge-store.d.ts +59 -0
- package/dist/knowledge-store.js +220 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type FlowStage } from "./types.js";
|
|
2
2
|
export interface LintFinding {
|
|
3
3
|
section: string;
|
|
4
4
|
required: boolean;
|
|
@@ -12,6 +12,36 @@ export interface LintResult {
|
|
|
12
12
|
passed: boolean;
|
|
13
13
|
findings: LintFinding[];
|
|
14
14
|
}
|
|
15
|
+
export declare function extractMarkdownSectionBody(markdown: string, section: string): string | null;
|
|
16
|
+
export type LearningEntryType = "rule" | "pattern" | "lesson" | "compound";
|
|
17
|
+
export type LearningConfidence = "high" | "medium" | "low";
|
|
18
|
+
export type LearningUniversality = "project" | "personal" | "universal";
|
|
19
|
+
export type LearningMaturity = "raw" | "lifted-to-rule" | "lifted-to-enforcement";
|
|
20
|
+
export interface LearningSeedEntry {
|
|
21
|
+
type: LearningEntryType;
|
|
22
|
+
trigger: string;
|
|
23
|
+
action: string;
|
|
24
|
+
confidence: LearningConfidence;
|
|
25
|
+
domain?: string | null;
|
|
26
|
+
stage?: FlowStage | null;
|
|
27
|
+
origin_stage?: FlowStage | null;
|
|
28
|
+
origin_feature?: string | null;
|
|
29
|
+
frequency?: number;
|
|
30
|
+
universality?: LearningUniversality;
|
|
31
|
+
maturity?: LearningMaturity;
|
|
32
|
+
created?: string;
|
|
33
|
+
first_seen_ts?: string;
|
|
34
|
+
last_seen_ts?: string;
|
|
35
|
+
project?: string | null;
|
|
36
|
+
}
|
|
37
|
+
export interface LearningsParseResult {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
none: boolean;
|
|
40
|
+
entries: LearningSeedEntry[];
|
|
41
|
+
errors: string[];
|
|
42
|
+
details: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function parseLearningsSection(sectionBody: string): LearningsParseResult;
|
|
15
45
|
export declare function lintArtifact(projectRoot: string, stage: FlowStage): Promise<LintResult>;
|
|
16
46
|
export declare function lintAllArtifacts(projectRoot: string): Promise<LintResult[]>;
|
|
17
47
|
export declare function validateReviewArmy(projectRoot: string): Promise<{
|
package/dist/artifact-linter.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { RUNTIME_ROOT } from "./constants.js";
|
|
4
4
|
import { exists } from "./fs-utils.js";
|
|
5
5
|
import { orderedStageSchemas, stageSchema } from "./content/stage-schema.js";
|
|
6
|
+
import { FLOW_STAGES } from "./types.js";
|
|
6
7
|
async function resolveArtifactPath(projectRoot, fileName) {
|
|
7
8
|
const relPath = path.join(RUNTIME_ROOT, "artifacts", fileName);
|
|
8
9
|
const absPath = path.join(projectRoot, relPath);
|
|
@@ -55,6 +56,9 @@ function sectionBodyByName(sections, section) {
|
|
|
55
56
|
}
|
|
56
57
|
return null;
|
|
57
58
|
}
|
|
59
|
+
export function extractMarkdownSectionBody(markdown, section) {
|
|
60
|
+
return sectionBodyByName(extractH2Sections(markdown), section);
|
|
61
|
+
}
|
|
58
62
|
function meaningfulLineCount(sectionBody) {
|
|
59
63
|
return sectionBody
|
|
60
64
|
.split(/\r?\n/)
|
|
@@ -179,6 +183,247 @@ function getMarkdownTableRows(sectionBody) {
|
|
|
179
183
|
}
|
|
180
184
|
return rows;
|
|
181
185
|
}
|
|
186
|
+
const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦)/u;
|
|
187
|
+
const DIAGRAM_FAILURE_EDGE_PATTERN = /\b(fail(?:ed|ure)?|error|timeout|fallback|degrad(?:e|ed|ation)|retry|backoff|circuit|unavailable|recover(?:y)?|rescue|mitigat(?:e|ion)|rollback|exception|abort|dead[\s-]?letter|dlq)\b/iu;
|
|
188
|
+
const DIAGRAM_GENERIC_NODE_PATTERN = /\b(service|component|module|system)\s*(?:[A-Z0-9])?\b/iu;
|
|
189
|
+
function diagramEdgeLines(sectionBody) {
|
|
190
|
+
return sectionBody
|
|
191
|
+
.split(/\r?\n/)
|
|
192
|
+
.map((line) => line.trim())
|
|
193
|
+
.filter((line) => line.length > 0)
|
|
194
|
+
.filter((line) => !line.startsWith("```"))
|
|
195
|
+
.filter((line) => !line.startsWith("%%"))
|
|
196
|
+
.filter((line) => DIAGRAM_ARROW_PATTERN.test(line));
|
|
197
|
+
}
|
|
198
|
+
function hasFailureEdgeInDiagram(sectionBody) {
|
|
199
|
+
const lines = diagramEdgeLines(sectionBody);
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
if (DIAGRAM_ARROW_PATTERN.test(line) && DIAGRAM_FAILURE_EDGE_PATTERN.test(line)) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
function hasLabeledDiagramArrow(lines) {
|
|
208
|
+
return lines.some((line) => /\|[^|]+\|/u.test(line) || /:\s*[A-Za-z]/u.test(line));
|
|
209
|
+
}
|
|
210
|
+
function hasAsyncDiagramEdge(lines) {
|
|
211
|
+
return lines.some((line) => /-\.->|-->>|~~>|\basync\b/iu.test(line));
|
|
212
|
+
}
|
|
213
|
+
function hasSyncDiagramEdge(lines) {
|
|
214
|
+
return lines.some((line) => {
|
|
215
|
+
if (/\bsync\b/iu.test(line))
|
|
216
|
+
return true;
|
|
217
|
+
if (!/(-->|->|=>|→|⟶|↦)/u.test(line))
|
|
218
|
+
return false;
|
|
219
|
+
return !/-\.->|-->>|~~>/u.test(line);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const LEARNING_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
|
|
223
|
+
const LEARNING_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
|
|
224
|
+
const LEARNING_UNIVERSALITY_SET = new Set(["project", "personal", "universal"]);
|
|
225
|
+
const LEARNING_MATURITY_SET = new Set(["raw", "lifted-to-rule", "lifted-to-enforcement"]);
|
|
226
|
+
const FLOW_STAGE_SET = new Set(FLOW_STAGES);
|
|
227
|
+
const LEARNING_ALLOWED_KEYS = new Set([
|
|
228
|
+
"type",
|
|
229
|
+
"trigger",
|
|
230
|
+
"action",
|
|
231
|
+
"confidence",
|
|
232
|
+
"domain",
|
|
233
|
+
"stage",
|
|
234
|
+
"origin_stage",
|
|
235
|
+
"origin_feature",
|
|
236
|
+
"frequency",
|
|
237
|
+
"universality",
|
|
238
|
+
"maturity",
|
|
239
|
+
"created",
|
|
240
|
+
"first_seen_ts",
|
|
241
|
+
"last_seen_ts",
|
|
242
|
+
"project"
|
|
243
|
+
]);
|
|
244
|
+
function isIsoUtcTimestamp(value) {
|
|
245
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value);
|
|
246
|
+
}
|
|
247
|
+
function isNullableString(value) {
|
|
248
|
+
return value === null || typeof value === "string";
|
|
249
|
+
}
|
|
250
|
+
function isNullableStage(value) {
|
|
251
|
+
return value === null || (typeof value === "string" && FLOW_STAGE_SET.has(value));
|
|
252
|
+
}
|
|
253
|
+
function parseLearningSeedEntry(raw, index) {
|
|
254
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
255
|
+
return { ok: false, error: `Learnings bullet #${index} must be a JSON object.` };
|
|
256
|
+
}
|
|
257
|
+
const obj = raw;
|
|
258
|
+
for (const key of Object.keys(obj)) {
|
|
259
|
+
if (!LEARNING_ALLOWED_KEYS.has(key)) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
error: `Learnings bullet #${index} includes unknown key "${key}" (allowed keys mirror knowledge JSONL fields).`
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
|
|
267
|
+
if (!LEARNING_TYPE_SET.has(type)) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: `Learnings bullet #${index} must set type to one of: rule, pattern, lesson, compound.`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const trigger = typeof obj.trigger === "string" ? obj.trigger.trim() : "";
|
|
274
|
+
if (trigger.length === 0) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
error: `Learnings bullet #${index} must include non-empty "trigger".`
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const action = typeof obj.action === "string" ? obj.action.trim() : "";
|
|
281
|
+
if (action.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
error: `Learnings bullet #${index} must include non-empty "action".`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const confidence = typeof obj.confidence === "string" ? obj.confidence.toLowerCase() : "";
|
|
288
|
+
if (!LEARNING_CONFIDENCE_SET.has(confidence)) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
error: `Learnings bullet #${index} must set confidence to high|medium|low.`
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (obj.domain !== undefined && !isNullableString(obj.domain)) {
|
|
295
|
+
return { ok: false, error: `Learnings bullet #${index} field "domain" must be string or null.` };
|
|
296
|
+
}
|
|
297
|
+
if (obj.stage !== undefined && !isNullableStage(obj.stage)) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: `Learnings bullet #${index} field "stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (obj.origin_stage !== undefined && !isNullableStage(obj.origin_stage)) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: `Learnings bullet #${index} field "origin_stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (obj.origin_feature !== undefined && !isNullableString(obj.origin_feature)) {
|
|
310
|
+
return { ok: false, error: `Learnings bullet #${index} field "origin_feature" must be string or null.` };
|
|
311
|
+
}
|
|
312
|
+
if (obj.project !== undefined && !isNullableString(obj.project)) {
|
|
313
|
+
return { ok: false, error: `Learnings bullet #${index} field "project" must be string or null.` };
|
|
314
|
+
}
|
|
315
|
+
if (obj.frequency !== undefined &&
|
|
316
|
+
(typeof obj.frequency !== "number" || !Number.isInteger(obj.frequency) || obj.frequency < 1)) {
|
|
317
|
+
return { ok: false, error: `Learnings bullet #${index} field "frequency" must be an integer >= 1.` };
|
|
318
|
+
}
|
|
319
|
+
if (obj.universality !== undefined &&
|
|
320
|
+
(typeof obj.universality !== "string" ||
|
|
321
|
+
!LEARNING_UNIVERSALITY_SET.has(obj.universality))) {
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
error: `Learnings bullet #${index} field "universality" must be project|personal|universal.`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (obj.maturity !== undefined &&
|
|
328
|
+
(typeof obj.maturity !== "string" || !LEARNING_MATURITY_SET.has(obj.maturity))) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
error: `Learnings bullet #${index} field "maturity" must be raw|lifted-to-rule|lifted-to-enforcement.`
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
for (const timestampField of ["created", "first_seen_ts", "last_seen_ts"]) {
|
|
335
|
+
const value = obj[timestampField];
|
|
336
|
+
if (value === undefined)
|
|
337
|
+
continue;
|
|
338
|
+
if (typeof value !== "string" || !isIsoUtcTimestamp(value)) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
error: `Learnings bullet #${index} field "${timestampField}" must be ISO UTC (YYYY-MM-DDTHH:MM:SSZ).`
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
entry: {
|
|
348
|
+
...obj,
|
|
349
|
+
type: type,
|
|
350
|
+
trigger,
|
|
351
|
+
action,
|
|
352
|
+
confidence: confidence
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
export function parseLearningsSection(sectionBody) {
|
|
357
|
+
const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
|
|
358
|
+
const nonEmpty = lines.filter((line) => line.length > 0);
|
|
359
|
+
const bullets = nonEmpty.filter((line) => /^-\s+\S+/u.test(line));
|
|
360
|
+
if (bullets.length === 0) {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
none: false,
|
|
364
|
+
entries: [],
|
|
365
|
+
errors: ["Learnings section must contain bullet entries."],
|
|
366
|
+
details: "Learnings section must contain bullet entries."
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const nonBulletContent = nonEmpty.filter((line) => !/^-\s+\S+/u.test(line));
|
|
370
|
+
if (nonBulletContent.length > 0) {
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
none: false,
|
|
374
|
+
entries: [],
|
|
375
|
+
errors: ["Learnings section must only contain bullet lines (one bullet per learning)."],
|
|
376
|
+
details: "Learnings section must only contain bullet lines (one bullet per learning)."
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (bullets.length === 1) {
|
|
380
|
+
const payload = bullets[0].replace(/^-\s+/u, "").trim();
|
|
381
|
+
if (/^none this stage\.?$/iu.test(payload)) {
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
none: true,
|
|
385
|
+
entries: [],
|
|
386
|
+
errors: [],
|
|
387
|
+
details: "Learnings section explicitly marked as none."
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const entries = [];
|
|
392
|
+
const errors = [];
|
|
393
|
+
for (let i = 0; i < bullets.length; i += 1) {
|
|
394
|
+
const payload = bullets[i].replace(/^-\s+/u, "").trim();
|
|
395
|
+
let parsed;
|
|
396
|
+
try {
|
|
397
|
+
parsed = JSON.parse(payload);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
errors.push(`Learnings bullet #${i + 1} must be valid JSON object or "None this stage.": ${err instanceof Error ? err.message : String(err)}`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const parsedEntry = parseLearningSeedEntry(parsed, i + 1);
|
|
404
|
+
if (!parsedEntry.ok || !parsedEntry.entry) {
|
|
405
|
+
errors.push(parsedEntry.error ?? `Learnings bullet #${i + 1} is invalid.`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
entries.push(parsedEntry.entry);
|
|
409
|
+
}
|
|
410
|
+
if (errors.length > 0) {
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
none: false,
|
|
414
|
+
entries: [],
|
|
415
|
+
errors,
|
|
416
|
+
details: errors.join(" | ")
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
none: false,
|
|
422
|
+
entries,
|
|
423
|
+
errors: [],
|
|
424
|
+
details: `Parsed ${entries.length} learning bullet(s) as knowledge-compatible JSON entries.`
|
|
425
|
+
};
|
|
426
|
+
}
|
|
182
427
|
function lineContainsVagueAdjective(text) {
|
|
183
428
|
const lower = text.toLowerCase();
|
|
184
429
|
for (const adjective of VAGUE_AC_ADJECTIVES) {
|
|
@@ -332,20 +577,57 @@ function validateSectionBody(sectionBody, rule, sectionName) {
|
|
|
332
577
|
};
|
|
333
578
|
}
|
|
334
579
|
}
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
const threshold = Math.ceil(keywords.length * 0.5);
|
|
340
|
-
if (found.length < threshold) {
|
|
341
|
-
const missing = keywords.filter((kw) => !bodyLower.includes(kw.toLowerCase()));
|
|
580
|
+
const sectionNameNormalized = normalizeHeadingTitle(sectionName).toLowerCase();
|
|
581
|
+
if (sectionNameNormalized === "architecture diagram") {
|
|
582
|
+
const edgeLines = diagramEdgeLines(sectionBody);
|
|
583
|
+
if (edgeLines.length === 0) {
|
|
342
584
|
return {
|
|
343
585
|
ok: false,
|
|
344
|
-
details:
|
|
586
|
+
details: "Architecture Diagram must include at least one directional edge line (for example `A -->|action| B`)."
|
|
345
587
|
};
|
|
346
588
|
}
|
|
589
|
+
if (!hasLabeledDiagramArrow(edgeLines)) {
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
details: "Architecture Diagram must label each edge with an action/message (for example `A -->|sync: persist| B`)."
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
const genericLine = edgeLines.find((line) => DIAGRAM_GENERIC_NODE_PATTERN.test(line));
|
|
596
|
+
if (genericLine) {
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
details: `Architecture Diagram uses a generic node label in edge "${genericLine}". Use concrete component names instead of placeholders like Service/Component.`
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (!hasAsyncDiagramEdge(edgeLines) || !hasSyncDiagramEdge(edgeLines)) {
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
details: "Architecture Diagram must distinguish sync vs async edges (for example solid + dotted arrows, or `sync:` and `async:` labels)."
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (!hasFailureEdgeInDiagram(sectionBody)) {
|
|
609
|
+
return {
|
|
610
|
+
ok: false,
|
|
611
|
+
details: "Architecture Diagram must include at least one failure-edge arrow with a failure keyword (for example: timeout, error, fallback, degraded, retry)."
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (sectionNameNormalized !== "architecture diagram") {
|
|
616
|
+
const keywords = extractRequiredKeywords(rule);
|
|
617
|
+
if (keywords.length > 0) {
|
|
618
|
+
const bodyLower = sectionBody.toLowerCase();
|
|
619
|
+
const found = keywords.filter((kw) => bodyLower.includes(kw.toLowerCase()));
|
|
620
|
+
const threshold = Math.ceil(keywords.length * 0.5);
|
|
621
|
+
if (found.length < threshold) {
|
|
622
|
+
const missing = keywords.filter((kw) => !bodyLower.includes(kw.toLowerCase()));
|
|
623
|
+
return {
|
|
624
|
+
ok: false,
|
|
625
|
+
details: `Rule expects keywords (${threshold}/${keywords.length} minimum): missing ${missing.join(", ")}.`
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
347
629
|
}
|
|
348
|
-
if (
|
|
630
|
+
if (sectionNameNormalized === "acceptance criteria" &&
|
|
349
631
|
/observable[\s,]*measurable[\s,]+(and )?falsifiable/iu.test(rule)) {
|
|
350
632
|
const rows = getMarkdownTableRows(sectionBody);
|
|
351
633
|
for (const row of rows) {
|
|
@@ -453,6 +735,27 @@ export async function lintArtifact(projectRoot, stage) {
|
|
|
453
735
|
: validation.details
|
|
454
736
|
});
|
|
455
737
|
}
|
|
738
|
+
const learningsBody = sectionBodyByName(sections, "Learnings");
|
|
739
|
+
const requireLearnings = parsedFrontmatter.hasFrontmatter;
|
|
740
|
+
if (learningsBody === null) {
|
|
741
|
+
findings.push({
|
|
742
|
+
section: "Learnings",
|
|
743
|
+
required: requireLearnings,
|
|
744
|
+
rule: "Required for schema-v1 artifacts: include `## Learnings` with bullets of strict JSON objects compatible with knowledge.jsonl schema, or a single `- None this stage.` sentinel.",
|
|
745
|
+
found: false,
|
|
746
|
+
details: "No ## heading matching required section \"Learnings\"."
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
const learnings = parseLearningsSection(learningsBody);
|
|
751
|
+
findings.push({
|
|
752
|
+
section: "Learnings",
|
|
753
|
+
required: requireLearnings,
|
|
754
|
+
rule: "`## Learnings` must contain either a single `- None this stage.` bullet or JSON bullets compatible with knowledge.jsonl fields (type/trigger/action/confidence required).",
|
|
755
|
+
found: learnings.ok,
|
|
756
|
+
details: learnings.details
|
|
757
|
+
});
|
|
758
|
+
}
|
|
456
759
|
if (stage === "plan") {
|
|
457
760
|
const strictPlanGuards = parsedFrontmatter.hasFrontmatter ||
|
|
458
761
|
headingPresent(sections, "No-Placeholder Scan") ||
|
|
@@ -49,6 +49,20 @@ Use the store to keep durable knowledge that should survive sessions:
|
|
|
49
49
|
- **lesson**: non-obvious outcome from a failure or trade-off.
|
|
50
50
|
- **compound**: post-ship insight about how to make the *next* feature faster (process accelerator, not domain rule).
|
|
51
51
|
|
|
52
|
+
## Continuous capture (stage closeout path)
|
|
53
|
+
|
|
54
|
+
Knowledge capture is now stage-native:
|
|
55
|
+
- Each stage artifact has a \`## Learnings\` section.
|
|
56
|
+
- Allowed payloads:
|
|
57
|
+
- \`- None this stage.\` (explicit no-op)
|
|
58
|
+
- JSON bullets with required keys \`type\`, \`trigger\`, \`action\`, \`confidence\` (optional keys may mirror the full JSONL schema fields).
|
|
59
|
+
- During \`bash .cclaw/hooks/stage-complete.sh <stage>\`, cclaw:
|
|
60
|
+
1. validates \`## Learnings\`,
|
|
61
|
+
2. appends deduped entries to \`${KNOWLEDGE_PATH}\`,
|
|
62
|
+
3. writes a harvest marker into the artifact.
|
|
63
|
+
|
|
64
|
+
\`/cc-learn\` remains the manual/query surface (search, backfill, curation).
|
|
65
|
+
|
|
52
66
|
## HARD-GATE
|
|
53
67
|
|
|
54
68
|
Under \`/cc-learn\`, only modify \`${KNOWLEDGE_PATH}\`, \`${KNOWLEDGE_ARCHIVE_PATH}\`,
|
|
@@ -113,6 +127,7 @@ Rules:
|
|
|
113
127
|
- Ask for required user-facing fields in order: \`type\`, \`trigger\`, \`action\`, \`confidence\`, \`domain\`, \`stage\`, \`universality\`, \`project\`.
|
|
114
128
|
- \`confidence\` must be one of \`high\`, \`medium\`, \`low\`. Default to \`medium\` if the user declines to set it.
|
|
115
129
|
- \`domain\`, \`stage\`, and \`project\` may be explicitly \`null\`.
|
|
130
|
+
- Prefer stage-native \`## Learnings\` capture for new flow work; use \`add\` mainly for backfilling historical lessons or ad-hoc entries outside a stage closeout.
|
|
116
131
|
- \`origin_stage\` defaults to \`stage\`; \`origin_feature\` defaults to active feature (or \`null\` if unknown).
|
|
117
132
|
- \`frequency\` starts at \`1\`.
|
|
118
133
|
- \`maturity\` starts at \`raw\`.
|
|
@@ -136,6 +151,11 @@ Manage the project knowledge store. One canonical file, strict JSONL:
|
|
|
136
151
|
- \`${KNOWLEDGE_PATH}\` — append-only JSONL, one entry per line.
|
|
137
152
|
- \`${KNOWLEDGE_ARCHIVE_PATH}\` — soft-archive target written only by curate.
|
|
138
153
|
|
|
154
|
+
Stage-native pipeline:
|
|
155
|
+
- During \`stage-complete.sh\`, cclaw harvests \`## Learnings\` from the current
|
|
156
|
+
stage artifact into \`${KNOWLEDGE_PATH}\` automatically.
|
|
157
|
+
- Use \`/cc-learn\` for query, backfill, and curation workflows.
|
|
158
|
+
|
|
139
159
|
## HARD-GATE
|
|
140
160
|
|
|
141
161
|
Do not edit source code from this command. Only operate on \`${KNOWLEDGE_PATH}\`,
|
package/dist/content/skills.js
CHANGED
|
@@ -223,9 +223,9 @@ function completionParametersBlock(schema) {
|
|
|
223
223
|
- \`artifact\`: \`${RUNTIME_ROOT}/artifacts/${schema.artifactFile}\`
|
|
224
224
|
- \`mandatory delegations\`: ${mandatory}
|
|
225
225
|
- \`completion helper\`: \`bash .cclaw/hooks/stage-complete.sh ${schema.stage}\`
|
|
226
|
+
- Fill \`## Learnings\` before closeout: either \`- None this stage.\` or JSON bullets with required keys \`type\`, \`trigger\`, \`action\`, \`confidence\` (knowledge-schema compatible).
|
|
226
227
|
- Record mandatory delegation completion/waiver in \`${RUNTIME_ROOT}/state/delegation-log.json\` with rationale as needed.
|
|
227
228
|
- Use the completion helper instead of raw \`flow-state.json\` edits (legacy direct edits trigger workflow-guard warnings or strict-mode blocks).
|
|
228
|
-
|
|
229
229
|
Apply shared completion logic from:
|
|
230
230
|
\`${COMPLETION_PROTOCOL_PATH}\`
|
|
231
231
|
`;
|
|
@@ -58,8 +58,15 @@ Rollback / fallback: <if decision proves wrong>
|
|
|
58
58
|
|
|
59
59
|
## Self-improvement reminder
|
|
60
60
|
|
|
61
|
-
Before closeout,
|
|
62
|
-
|
|
61
|
+
Before closeout, fill the artifact \`## Learnings\` section (do not write
|
|
62
|
+
\`.cclaw/knowledge.jsonl\` by hand):
|
|
63
|
+
- \`- None this stage.\` when nothing reusable emerged.
|
|
64
|
+
- Or 1-3 JSON bullets with required keys \`type\`, \`trigger\`, \`action\`,
|
|
65
|
+
\`confidence\` (optional fields may mirror knowledge.jsonl schema keys).
|
|
66
|
+
During \`bash .cclaw/hooks/stage-complete.sh <stage>\`, cclaw validates those
|
|
67
|
+
bullets, appends unique entries to \`.cclaw/knowledge.jsonl\`, and stamps a
|
|
68
|
+
harvest marker in the artifact.
|
|
69
|
+
|
|
63
70
|
Prefer \`type=rule|pattern|lesson\` (\`compound\` stays retro-focused).
|
|
64
71
|
|
|
65
72
|
Track policy:
|
|
@@ -67,8 +74,8 @@ Track policy:
|
|
|
67
74
|
recommended for other stages.
|
|
68
75
|
- \`quick\`: recommended only.
|
|
69
76
|
|
|
70
|
-
|
|
71
|
-
(for example, purely mechanical edits with no new decisions).
|
|
77
|
+
\`- None this stage.\` is acceptable only when the stage produced no reusable
|
|
78
|
+
insight (for example, purely mechanical edits with no new decisions).
|
|
72
79
|
|
|
73
80
|
## Progressive disclosure baseline
|
|
74
81
|
|
|
@@ -25,7 +25,7 @@ export const DESIGN = {
|
|
|
25
25
|
"Codebase Investigation — Before any design decision, read the actual code in the blast radius. List every file that will be touched, its current responsibilities, and existing patterns (error handling, naming, test style). Design must conform to discovered patterns, not impose new ones without justification.",
|
|
26
26
|
"Step 0: Scope Challenge — what existing code solves sub-problems? Minimum change set? Complexity check: 8+ files or 2+ new services = complexity smell → flag for possible scope reduction.",
|
|
27
27
|
"Search Before Building — For each technical choice (library, pattern, architecture), search for existing solutions. Label findings: Layer 1 (exact match), Layer 2 (partial match, needs adaptation), Layer 3 (inspiration only), EUREKA (unexpected perfect solution). Default to existing before custom.",
|
|
28
|
-
"Architecture Review — system design, component boundaries, data flow, scaling, security architecture. For each new codepath: one realistic production failure scenario. **Mandatory:** produce at least one architecture diagram (ASCII, Mermaid, or tool-generated) showing component boundaries and data flow direction. Apply the **Visual Communication rules** (see below) — an unlabeled or generic diagram is worse than no diagram, because it pretends to encode decisions it does not.",
|
|
28
|
+
"Architecture Review — system design, component boundaries, data flow, scaling, security architecture. For each new codepath: one realistic production failure scenario. **Mandatory:** produce at least one architecture diagram (ASCII, Mermaid, or tool-generated) showing component boundaries and data flow direction. Include at least one labeled failure edge, e.g. `API -->|timeout| FallbackCache -->|degraded response| User`. Apply the **Visual Communication rules** (see below) — an unlabeled or generic diagram is worse than no diagram, because it pretends to encode decisions it does not.",
|
|
29
29
|
"Code Quality Review — code organization, DRY violations, error handling patterns, over/under-engineering assessment.",
|
|
30
30
|
"Test Review — diagram every new flow, data path, error path. For each: what test type covers it? Does one exist? What is the gap? Produce test plan artifact.",
|
|
31
31
|
"Performance Review — N+1 queries, memory concerns, caching opportunities, slow code paths. What breaks at 10x load? At 100x?",
|
|
@@ -196,7 +196,7 @@ export const DESIGN = {
|
|
|
196
196
|
{ section: "Codebase Investigation", required: true, validationRule: "Must list blast-radius files with current responsibilities and discovered patterns." },
|
|
197
197
|
{ section: "Search Before Building", required: true, validationRule: "For each technical choice: Layer 1 (exact match), Layer 2 (partial match), Layer 3 (inspiration), EUREKA labels with reuse-first default." },
|
|
198
198
|
{ section: "Architecture Boundaries", required: true, validationRule: "Must list component boundaries with ownership." },
|
|
199
|
-
{ section: "Architecture Diagram", required: true, validationRule: "At least one diagram (ASCII, Mermaid, or image) showing component boundaries and data flow direction. Diagram must: (1) label every node with a concrete component name (no generic 'Service A/B'), (2) label every arrow with the action or message (no unlabeled arrows), (3) mark direction of data flow explicitly, (4) distinguish synchronous from asynchronous edges (e.g. solid vs dashed, or `sync:` / `async:` prefix), (5)
|
|
199
|
+
{ section: "Architecture Diagram", required: true, validationRule: "At least one diagram (ASCII, Mermaid, or image) showing component boundaries and data flow direction. Diagram must: (1) label every node with a concrete component name (no generic 'Service A/B'), (2) label every arrow with the action or message (no unlabeled arrows), (3) mark direction of data flow explicitly, (4) distinguish synchronous from asynchronous edges (e.g. solid vs dashed, or `sync:` / `async:` prefix), (5) include at least one failure/degraded edge line that contains an arrow plus a failure keyword (`timeout`, `error`, `fallback`, `degraded`, `retry`, etc.)." },
|
|
200
200
|
{ section: "Data Flow", required: true, validationRule: "Must include happy path, nil input, empty input, upstream error paths." },
|
|
201
201
|
{ section: "Failure Mode Table", required: true, validationRule: "Each failure mode has: trigger, detection, mitigation, user impact." },
|
|
202
202
|
{ section: "Test Strategy", required: true, validationRule: "Must define unit/integration/e2e expectations with coverage targets." },
|
|
@@ -45,6 +45,9 @@ inputs_hash: sha256:pending
|
|
|
45
45
|
## Assumptions and Open Questions
|
|
46
46
|
- **Assumptions:**
|
|
47
47
|
- **Open questions (or "None"):**
|
|
48
|
+
|
|
49
|
+
## Learnings
|
|
50
|
+
- None this stage.
|
|
48
51
|
`,
|
|
49
52
|
"02-scope.md": `---
|
|
50
53
|
stage: scope
|
|
@@ -148,6 +151,9 @@ inputs_hash: sha256:pending
|
|
|
148
151
|
- Accepted scope:
|
|
149
152
|
- Deferred:
|
|
150
153
|
- Explicitly excluded:
|
|
154
|
+
|
|
155
|
+
## Learnings
|
|
156
|
+
- None this stage.
|
|
151
157
|
`,
|
|
152
158
|
"03-design.md": `---
|
|
153
159
|
stage: design
|
|
@@ -241,6 +247,9 @@ inputs_hash: sha256:pending
|
|
|
241
247
|
| Distribution & Delivery Review | | |
|
|
242
248
|
|
|
243
249
|
**Decisions made:** 0 | **Unresolved:** 0
|
|
250
|
+
|
|
251
|
+
## Learnings
|
|
252
|
+
- None this stage.
|
|
244
253
|
`,
|
|
245
254
|
"04-spec.md": `---
|
|
246
255
|
stage: spec
|
|
@@ -294,6 +303,9 @@ inputs_hash: sha256:pending
|
|
|
294
303
|
## Approval
|
|
295
304
|
- Approved by:
|
|
296
305
|
- Date:
|
|
306
|
+
|
|
307
|
+
## Learnings
|
|
308
|
+
- None this stage.
|
|
297
309
|
`,
|
|
298
310
|
"05-plan.md": `---
|
|
299
311
|
stage: plan
|
|
@@ -370,6 +382,9 @@ Execution rule: complete and verify each batch before starting the next batch.
|
|
|
370
382
|
## WAIT_FOR_CONFIRM
|
|
371
383
|
- Status: pending
|
|
372
384
|
- Confirmed by:
|
|
385
|
+
|
|
386
|
+
## Learnings
|
|
387
|
+
- None this stage.
|
|
373
388
|
`,
|
|
374
389
|
"06-tdd.md": `---
|
|
375
390
|
stage: tdd
|
|
@@ -434,6 +449,9 @@ inputs_hash: sha256:pending
|
|
|
434
449
|
| Slice | Reproduction test | RED-without-fix evidence | GREEN-with-fix evidence | Revert-guard note |
|
|
435
450
|
|---|---|---|---|---|
|
|
436
451
|
| S-1 | | | | |
|
|
452
|
+
|
|
453
|
+
## Learnings
|
|
454
|
+
- None this stage.
|
|
437
455
|
`,
|
|
438
456
|
"07-review.md": `---
|
|
439
457
|
stage: review
|
|
@@ -504,6 +522,9 @@ inputs_hash: sha256:pending
|
|
|
504
522
|
|
|
505
523
|
## Final Verdict
|
|
506
524
|
- APPROVED | APPROVED_WITH_CONCERNS | BLOCKED
|
|
525
|
+
|
|
526
|
+
## Learnings
|
|
527
|
+
- None this stage.
|
|
507
528
|
`,
|
|
508
529
|
"07-review-army.json": `{
|
|
509
530
|
"version": 1,
|
|
@@ -571,6 +592,9 @@ inputs_hash: sha256:pending
|
|
|
571
592
|
- Run \`/cc-ops retro\` before archive.
|
|
572
593
|
- Retro artifact path: \`.cclaw/artifacts/09-retro.md\`
|
|
573
594
|
- Archive remains blocked until retro gate is complete.
|
|
595
|
+
|
|
596
|
+
## Learnings
|
|
597
|
+
- None this stage.
|
|
574
598
|
`,
|
|
575
599
|
"09-retro.md": `---
|
|
576
600
|
stage: retro
|
|
@@ -611,6 +635,9 @@ inputs_hash: sha256:pending
|
|
|
611
635
|
- RETRO_COMPLETE: yes
|
|
612
636
|
- Completed at (UTC):
|
|
613
637
|
- Notes:
|
|
638
|
+
|
|
639
|
+
## Learnings
|
|
640
|
+
- None this stage.
|
|
614
641
|
`
|
|
615
642
|
};
|
|
616
643
|
export const RULEBOOK_MARKDOWN = `# Cclaw Rulebook
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RUNTIME_ROOT } from "../constants.js";
|
|
2
4
|
import { stageSchema } from "../content/stage-schema.js";
|
|
3
5
|
import { appendDelegation, checkMandatoryDelegations } from "../delegation.js";
|
|
6
|
+
import { readActiveFeature } from "../feature-system.js";
|
|
4
7
|
import { verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence } from "../gate-evidence.js";
|
|
8
|
+
import { extractMarkdownSectionBody, parseLearningsSection } from "../artifact-linter.js";
|
|
5
9
|
import { isFlowTrack, nextStage } from "../flow-state.js";
|
|
10
|
+
import { appendKnowledge } from "../knowledge-store.js";
|
|
6
11
|
import { readFlowState, writeFlowState } from "../runs.js";
|
|
7
12
|
import { FLOW_STAGES } from "../types.js";
|
|
8
13
|
function unique(values) {
|
|
@@ -241,6 +246,89 @@ async function buildValidationReport(projectRoot, flowState) {
|
|
|
241
246
|
}
|
|
242
247
|
};
|
|
243
248
|
}
|
|
249
|
+
const LEARNINGS_HARVEST_MARKER_PREFIX = "<!-- cclaw:learnings-harvested:";
|
|
250
|
+
function withLearningsHarvestMarker(artifactMarkdown, appendedEntries, skippedDuplicates) {
|
|
251
|
+
const suffix = artifactMarkdown.endsWith("\n") ? "" : "\n";
|
|
252
|
+
return `${artifactMarkdown}${suffix}${LEARNINGS_HARVEST_MARKER_PREFIX}${new Date().toISOString()} appended=${appendedEntries} skipped=${skippedDuplicates} -->\n`;
|
|
253
|
+
}
|
|
254
|
+
async function harvestStageLearnings(projectRoot, stage, artifactFile) {
|
|
255
|
+
const artifactPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", artifactFile);
|
|
256
|
+
let raw = "";
|
|
257
|
+
try {
|
|
258
|
+
raw = await fs.readFile(artifactPath, "utf8");
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
markerWritten: false,
|
|
264
|
+
parsedEntries: 0,
|
|
265
|
+
appendedEntries: 0,
|
|
266
|
+
skippedDuplicates: 0,
|
|
267
|
+
details: `Unable to read artifact for learnings harvest (${artifactPath}): ${err instanceof Error ? err.message : String(err)}`
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (raw.includes(LEARNINGS_HARVEST_MARKER_PREFIX)) {
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
markerWritten: false,
|
|
274
|
+
parsedEntries: 0,
|
|
275
|
+
appendedEntries: 0,
|
|
276
|
+
skippedDuplicates: 0,
|
|
277
|
+
details: "Learnings already harvested for this artifact."
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const learningsBody = extractMarkdownSectionBody(raw, "Learnings");
|
|
281
|
+
if (learningsBody === null) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
markerWritten: false,
|
|
285
|
+
parsedEntries: 0,
|
|
286
|
+
appendedEntries: 0,
|
|
287
|
+
skippedDuplicates: 0,
|
|
288
|
+
details: 'Artifact is missing required "## Learnings" section.'
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const parsed = parseLearningsSection(learningsBody);
|
|
292
|
+
if (!parsed.ok) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
markerWritten: false,
|
|
296
|
+
parsedEntries: 0,
|
|
297
|
+
appendedEntries: 0,
|
|
298
|
+
skippedDuplicates: 0,
|
|
299
|
+
details: parsed.details
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const activeFeature = await readActiveFeature(projectRoot).catch(() => null);
|
|
303
|
+
const appendResult = await appendKnowledge(projectRoot, parsed.entries, {
|
|
304
|
+
stage,
|
|
305
|
+
originStage: stage,
|
|
306
|
+
originFeature: activeFeature,
|
|
307
|
+
project: path.basename(projectRoot)
|
|
308
|
+
});
|
|
309
|
+
if (appendResult.invalid > 0) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
markerWritten: false,
|
|
313
|
+
parsedEntries: parsed.entries.length,
|
|
314
|
+
appendedEntries: appendResult.appended,
|
|
315
|
+
skippedDuplicates: appendResult.skippedDuplicates,
|
|
316
|
+
details: `Learnings append failed schema checks: ${appendResult.errors.join(" | ")}`
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const withMarker = withLearningsHarvestMarker(raw, appendResult.appended, appendResult.skippedDuplicates);
|
|
320
|
+
await fs.writeFile(artifactPath, withMarker, "utf8");
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
markerWritten: true,
|
|
324
|
+
parsedEntries: parsed.entries.length,
|
|
325
|
+
appendedEntries: appendResult.appended,
|
|
326
|
+
skippedDuplicates: appendResult.skippedDuplicates,
|
|
327
|
+
details: parsed.none
|
|
328
|
+
? "Learnings section marked none; harvest marker recorded."
|
|
329
|
+
: `Harvested ${appendResult.appended} learning entr${appendResult.appended === 1 ? "y" : "ies"} (${appendResult.skippedDuplicates} duplicate skipped).`
|
|
330
|
+
};
|
|
331
|
+
}
|
|
244
332
|
async function runAdvanceStage(projectRoot, args, io) {
|
|
245
333
|
const flowState = await readFlowState(projectRoot);
|
|
246
334
|
if (flowState.currentStage !== args.stage) {
|
|
@@ -335,6 +423,11 @@ async function runAdvanceStage(projectRoot, args, io) {
|
|
|
335
423
|
}
|
|
336
424
|
return 1;
|
|
337
425
|
}
|
|
426
|
+
const learningsHarvest = await harvestStageLearnings(projectRoot, args.stage, schema.artifactFile);
|
|
427
|
+
if (!learningsHarvest.ok) {
|
|
428
|
+
io.stderr.write(`cclaw internal advance-stage: learnings harvest failed for "${schema.artifactFile}". ${learningsHarvest.details}\n`);
|
|
429
|
+
return 1;
|
|
430
|
+
}
|
|
338
431
|
const successor = nextStage(args.stage, flowState.track);
|
|
339
432
|
const completedStages = flowState.completedStages.includes(args.stage)
|
|
340
433
|
? [...flowState.completedStages]
|
|
@@ -352,7 +445,14 @@ async function runAdvanceStage(projectRoot, args, io) {
|
|
|
352
445
|
stage: args.stage,
|
|
353
446
|
nextStage: successor,
|
|
354
447
|
currentStage: finalState.currentStage,
|
|
355
|
-
completedStages: finalState.completedStages
|
|
448
|
+
completedStages: finalState.completedStages,
|
|
449
|
+
learnings: {
|
|
450
|
+
parsed: learningsHarvest.parsedEntries,
|
|
451
|
+
appended: learningsHarvest.appendedEntries,
|
|
452
|
+
skippedDuplicates: learningsHarvest.skippedDuplicates,
|
|
453
|
+
markerWritten: learningsHarvest.markerWritten,
|
|
454
|
+
details: learningsHarvest.details
|
|
455
|
+
}
|
|
356
456
|
}, null, 2)}\n`);
|
|
357
457
|
}
|
|
358
458
|
return 0;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type FlowStage } from "./types.js";
|
|
2
|
+
export type KnowledgeEntryType = "rule" | "pattern" | "lesson" | "compound";
|
|
3
|
+
export type KnowledgeEntryConfidence = "high" | "medium" | "low";
|
|
4
|
+
export type KnowledgeEntryUniversality = "project" | "personal" | "universal";
|
|
5
|
+
export type KnowledgeEntryMaturity = "raw" | "lifted-to-rule" | "lifted-to-enforcement";
|
|
6
|
+
export interface KnowledgeEntry {
|
|
7
|
+
type: KnowledgeEntryType;
|
|
8
|
+
trigger: string;
|
|
9
|
+
action: string;
|
|
10
|
+
confidence: KnowledgeEntryConfidence;
|
|
11
|
+
domain: string | null;
|
|
12
|
+
stage: FlowStage | null;
|
|
13
|
+
origin_stage: FlowStage | null;
|
|
14
|
+
origin_feature: string | null;
|
|
15
|
+
frequency: number;
|
|
16
|
+
universality: KnowledgeEntryUniversality;
|
|
17
|
+
maturity: KnowledgeEntryMaturity;
|
|
18
|
+
created: string;
|
|
19
|
+
first_seen_ts: string;
|
|
20
|
+
last_seen_ts: string;
|
|
21
|
+
project: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface KnowledgeSeedEntry {
|
|
24
|
+
type: KnowledgeEntryType;
|
|
25
|
+
trigger: string;
|
|
26
|
+
action: string;
|
|
27
|
+
confidence: KnowledgeEntryConfidence;
|
|
28
|
+
domain?: string | null;
|
|
29
|
+
stage?: FlowStage | null;
|
|
30
|
+
origin_stage?: FlowStage | null;
|
|
31
|
+
origin_feature?: string | null;
|
|
32
|
+
frequency?: number;
|
|
33
|
+
universality?: KnowledgeEntryUniversality;
|
|
34
|
+
maturity?: KnowledgeEntryMaturity;
|
|
35
|
+
created?: string;
|
|
36
|
+
first_seen_ts?: string;
|
|
37
|
+
last_seen_ts?: string;
|
|
38
|
+
project?: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface AppendKnowledgeDefaults {
|
|
41
|
+
stage?: FlowStage | null;
|
|
42
|
+
originStage?: FlowStage | null;
|
|
43
|
+
originFeature?: string | null;
|
|
44
|
+
project?: string | null;
|
|
45
|
+
nowIso?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface AppendKnowledgeResult {
|
|
48
|
+
appended: number;
|
|
49
|
+
skippedDuplicates: number;
|
|
50
|
+
invalid: number;
|
|
51
|
+
errors: string[];
|
|
52
|
+
appendedEntries: KnowledgeEntry[];
|
|
53
|
+
}
|
|
54
|
+
export declare function validateKnowledgeEntry(entry: unknown): {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
errors: string[];
|
|
57
|
+
};
|
|
58
|
+
export declare function materializeKnowledgeEntry(seed: KnowledgeSeedEntry, defaults?: AppendKnowledgeDefaults): KnowledgeEntry;
|
|
59
|
+
export declare function appendKnowledge(projectRoot: string, seeds: KnowledgeSeedEntry[], defaults?: AppendKnowledgeDefaults): Promise<AppendKnowledgeResult>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RUNTIME_ROOT } from "./constants.js";
|
|
4
|
+
import { withDirectoryLock } from "./fs-utils.js";
|
|
5
|
+
import { FLOW_STAGES } from "./types.js";
|
|
6
|
+
const KNOWLEDGE_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
|
|
7
|
+
const KNOWLEDGE_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
|
|
8
|
+
const KNOWLEDGE_UNIVERSALITY_SET = new Set(["project", "personal", "universal"]);
|
|
9
|
+
const KNOWLEDGE_MATURITY_SET = new Set(["raw", "lifted-to-rule", "lifted-to-enforcement"]);
|
|
10
|
+
const FLOW_STAGE_SET = new Set(FLOW_STAGES);
|
|
11
|
+
const KNOWLEDGE_REQUIRED_KEYS = [
|
|
12
|
+
"type",
|
|
13
|
+
"trigger",
|
|
14
|
+
"action",
|
|
15
|
+
"confidence",
|
|
16
|
+
"domain",
|
|
17
|
+
"stage",
|
|
18
|
+
"origin_stage",
|
|
19
|
+
"origin_feature",
|
|
20
|
+
"frequency",
|
|
21
|
+
"universality",
|
|
22
|
+
"maturity",
|
|
23
|
+
"created",
|
|
24
|
+
"first_seen_ts",
|
|
25
|
+
"last_seen_ts",
|
|
26
|
+
"project"
|
|
27
|
+
];
|
|
28
|
+
const KNOWLEDGE_ALLOWED_KEYS = new Set(KNOWLEDGE_REQUIRED_KEYS);
|
|
29
|
+
function knowledgePath(projectRoot) {
|
|
30
|
+
return path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
|
|
31
|
+
}
|
|
32
|
+
function knowledgeLockPath(projectRoot) {
|
|
33
|
+
return path.join(projectRoot, RUNTIME_ROOT, "state", ".knowledge.lock");
|
|
34
|
+
}
|
|
35
|
+
function normalizeUtcIso(iso) {
|
|
36
|
+
return iso.replace(/\.\d{3}Z$/u, "Z");
|
|
37
|
+
}
|
|
38
|
+
function nowUtcIso() {
|
|
39
|
+
return normalizeUtcIso(new Date().toISOString());
|
|
40
|
+
}
|
|
41
|
+
function normalizeText(value) {
|
|
42
|
+
return value.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
function dedupeKey(entry) {
|
|
45
|
+
return [
|
|
46
|
+
entry.type,
|
|
47
|
+
normalizeText(entry.trigger),
|
|
48
|
+
normalizeText(entry.action),
|
|
49
|
+
entry.domain === null ? "null" : normalizeText(entry.domain),
|
|
50
|
+
entry.stage ?? "null",
|
|
51
|
+
entry.origin_stage ?? "null",
|
|
52
|
+
entry.origin_feature === null ? "null" : normalizeText(entry.origin_feature),
|
|
53
|
+
entry.universality,
|
|
54
|
+
entry.project === null ? "null" : normalizeText(entry.project)
|
|
55
|
+
].join("|");
|
|
56
|
+
}
|
|
57
|
+
function isIsoUtcTimestamp(value) {
|
|
58
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value);
|
|
59
|
+
}
|
|
60
|
+
function isNullableString(value) {
|
|
61
|
+
return value === null || typeof value === "string";
|
|
62
|
+
}
|
|
63
|
+
function isNullableStage(value) {
|
|
64
|
+
return value === null || (typeof value === "string" && FLOW_STAGE_SET.has(value));
|
|
65
|
+
}
|
|
66
|
+
export function validateKnowledgeEntry(entry) {
|
|
67
|
+
const errors = [];
|
|
68
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
69
|
+
return { ok: false, errors: ["Knowledge entry must be a JSON object."] };
|
|
70
|
+
}
|
|
71
|
+
const obj = entry;
|
|
72
|
+
for (const key of Object.keys(obj)) {
|
|
73
|
+
if (!KNOWLEDGE_ALLOWED_KEYS.has(key)) {
|
|
74
|
+
errors.push(`Unknown key "${key}" in knowledge entry.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const key of KNOWLEDGE_REQUIRED_KEYS) {
|
|
78
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
79
|
+
errors.push(`Missing required key "${key}".`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!KNOWLEDGE_TYPE_SET.has(obj.type)) {
|
|
83
|
+
errors.push("type must be one of: rule, pattern, lesson, compound.");
|
|
84
|
+
}
|
|
85
|
+
if (typeof obj.trigger !== "string" || obj.trigger.trim().length === 0) {
|
|
86
|
+
errors.push("trigger must be a non-empty string.");
|
|
87
|
+
}
|
|
88
|
+
if (typeof obj.action !== "string" || obj.action.trim().length === 0) {
|
|
89
|
+
errors.push("action must be a non-empty string.");
|
|
90
|
+
}
|
|
91
|
+
if (!KNOWLEDGE_CONFIDENCE_SET.has(obj.confidence)) {
|
|
92
|
+
errors.push("confidence must be one of: high, medium, low.");
|
|
93
|
+
}
|
|
94
|
+
if (!isNullableString(obj.domain)) {
|
|
95
|
+
errors.push("domain must be string or null.");
|
|
96
|
+
}
|
|
97
|
+
if (!isNullableStage(obj.stage)) {
|
|
98
|
+
errors.push(`stage must be one of ${FLOW_STAGES.join(", ")} or null.`);
|
|
99
|
+
}
|
|
100
|
+
if (!isNullableStage(obj.origin_stage)) {
|
|
101
|
+
errors.push(`origin_stage must be one of ${FLOW_STAGES.join(", ")} or null.`);
|
|
102
|
+
}
|
|
103
|
+
if (!isNullableString(obj.origin_feature)) {
|
|
104
|
+
errors.push("origin_feature must be string or null.");
|
|
105
|
+
}
|
|
106
|
+
if (typeof obj.frequency !== "number" ||
|
|
107
|
+
!Number.isInteger(obj.frequency) ||
|
|
108
|
+
obj.frequency < 1) {
|
|
109
|
+
errors.push("frequency must be an integer >= 1.");
|
|
110
|
+
}
|
|
111
|
+
if (!KNOWLEDGE_UNIVERSALITY_SET.has(obj.universality)) {
|
|
112
|
+
errors.push("universality must be one of: project, personal, universal.");
|
|
113
|
+
}
|
|
114
|
+
if (!KNOWLEDGE_MATURITY_SET.has(obj.maturity)) {
|
|
115
|
+
errors.push("maturity must be one of: raw, lifted-to-rule, lifted-to-enforcement.");
|
|
116
|
+
}
|
|
117
|
+
for (const timestampField of ["created", "first_seen_ts", "last_seen_ts"]) {
|
|
118
|
+
const value = obj[timestampField];
|
|
119
|
+
if (typeof value !== "string" || !isIsoUtcTimestamp(value)) {
|
|
120
|
+
errors.push(`${timestampField} must be ISO UTC (YYYY-MM-DDTHH:MM:SSZ).`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!isNullableString(obj.project)) {
|
|
124
|
+
errors.push("project must be string or null.");
|
|
125
|
+
}
|
|
126
|
+
return { ok: errors.length === 0, errors };
|
|
127
|
+
}
|
|
128
|
+
export function materializeKnowledgeEntry(seed, defaults = {}) {
|
|
129
|
+
const now = normalizeUtcIso(defaults.nowIso ?? nowUtcIso());
|
|
130
|
+
const stage = seed.stage ?? defaults.stage ?? null;
|
|
131
|
+
const originStage = seed.origin_stage ?? defaults.originStage ?? stage ?? null;
|
|
132
|
+
return {
|
|
133
|
+
type: seed.type,
|
|
134
|
+
trigger: seed.trigger.trim(),
|
|
135
|
+
action: seed.action.trim(),
|
|
136
|
+
confidence: seed.confidence,
|
|
137
|
+
domain: seed.domain ?? null,
|
|
138
|
+
stage,
|
|
139
|
+
origin_stage: originStage,
|
|
140
|
+
origin_feature: seed.origin_feature ?? defaults.originFeature ?? null,
|
|
141
|
+
frequency: seed.frequency ?? 1,
|
|
142
|
+
universality: seed.universality ?? "project",
|
|
143
|
+
maturity: seed.maturity ?? "raw",
|
|
144
|
+
created: normalizeUtcIso(seed.created ?? now),
|
|
145
|
+
first_seen_ts: normalizeUtcIso(seed.first_seen_ts ?? now),
|
|
146
|
+
last_seen_ts: normalizeUtcIso(seed.last_seen_ts ?? now),
|
|
147
|
+
project: seed.project ?? defaults.project ?? null
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async function readExistingKnowledgeKeys(filePath) {
|
|
151
|
+
const keys = new Set();
|
|
152
|
+
try {
|
|
153
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
154
|
+
const lines = raw.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(line);
|
|
158
|
+
const validated = validateKnowledgeEntry(parsed);
|
|
159
|
+
if (!validated.ok)
|
|
160
|
+
continue;
|
|
161
|
+
const entry = parsed;
|
|
162
|
+
keys.add(dedupeKey(entry));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Ignore malformed historical lines for dedupe indexing.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Missing file is fine — treat as empty store.
|
|
171
|
+
}
|
|
172
|
+
return keys;
|
|
173
|
+
}
|
|
174
|
+
export async function appendKnowledge(projectRoot, seeds, defaults = {}) {
|
|
175
|
+
if (seeds.length === 0) {
|
|
176
|
+
return { appended: 0, skippedDuplicates: 0, invalid: 0, errors: [], appendedEntries: [] };
|
|
177
|
+
}
|
|
178
|
+
const filePath = knowledgePath(projectRoot);
|
|
179
|
+
const errors = [];
|
|
180
|
+
const materialized = [];
|
|
181
|
+
for (let i = 0; i < seeds.length; i += 1) {
|
|
182
|
+
const seed = seeds[i];
|
|
183
|
+
const entry = materializeKnowledgeEntry(seed, defaults);
|
|
184
|
+
const validated = validateKnowledgeEntry(entry);
|
|
185
|
+
if (!validated.ok) {
|
|
186
|
+
errors.push(`entry #${i + 1}: ${validated.errors.join(" ")}`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
materialized.push(entry);
|
|
190
|
+
}
|
|
191
|
+
let skippedDuplicates = 0;
|
|
192
|
+
const appendedEntries = [];
|
|
193
|
+
await withDirectoryLock(knowledgeLockPath(projectRoot), async () => {
|
|
194
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
195
|
+
const existingKeys = await readExistingKnowledgeKeys(filePath);
|
|
196
|
+
const batchKeys = new Set();
|
|
197
|
+
const linesToAppend = [];
|
|
198
|
+
for (const entry of materialized) {
|
|
199
|
+
const key = dedupeKey(entry);
|
|
200
|
+
if (existingKeys.has(key) || batchKeys.has(key)) {
|
|
201
|
+
skippedDuplicates += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
batchKeys.add(key);
|
|
205
|
+
existingKeys.add(key);
|
|
206
|
+
appendedEntries.push(entry);
|
|
207
|
+
linesToAppend.push(JSON.stringify(entry));
|
|
208
|
+
}
|
|
209
|
+
if (linesToAppend.length > 0) {
|
|
210
|
+
await fs.appendFile(filePath, `${linesToAppend.join("\n")}\n`, "utf8");
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
appended: appendedEntries.length,
|
|
215
|
+
skippedDuplicates,
|
|
216
|
+
invalid: errors.length,
|
|
217
|
+
errors,
|
|
218
|
+
appendedEntries
|
|
219
|
+
};
|
|
220
|
+
}
|