@brainervirus/workit-core 7.2.0 → 7.4.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/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +29 -20
- package/skills/workit-fanout/references/brief.md +45 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +3 -2
- package/skills/workit-shape/references/knowledge.md +3 -0
- package/skills/workit-ship/SKILL.md +2 -1
- package/src/core/methods.ts +9 -10
- package/src/core/skill-manifests.ts +3 -0
- package/src/fanout-check.ts +294 -0
- package/src/fanout.ts +916 -0
- package/src/knowledge.ts +368 -0
package/src/knowledge.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// `workit knowledge lint`: deterministic checks over the repository's agent
|
|
2
|
+
// knowledge files, so the guards against markdown rot are a check that can
|
|
3
|
+
// fail rather than a sentence an agent may ignore. Four rules:
|
|
4
|
+
//
|
|
5
|
+
// - agents-budget: AGENTS.md (and a CLAUDE.md that is not the same file) stays
|
|
6
|
+
// at or under 8 KB; everything an agent pays for on every session.
|
|
7
|
+
// - broken-link: a markdown link, or a backticked path whose first segment
|
|
8
|
+
// exists at the repository top, points at a local file that is missing.
|
|
9
|
+
// - scaffold-file: a need-based file (CODING_STANDARDS.md, GLOSSARY.md) holds
|
|
10
|
+
// only headings, comments or placeholders. These files are created in the
|
|
11
|
+
// same edit as their first real entry, never as an empty scaffold.
|
|
12
|
+
// - duplicate-rule: the same sentence (normalized: case, markup, wrapping)
|
|
13
|
+
// appears in a steering file and in CODING_STANDARDS.md. A rule lives once.
|
|
14
|
+
//
|
|
15
|
+
// Read-only; plain TS over node built-ins (git only to list tracked paths).
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
export const AGENTS_BYTE_BUDGET = 8 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Root files every session reads (navigation and rules). */
|
|
23
|
+
const STEERING_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
24
|
+
/** Root files that exist only once they hold a real entry. */
|
|
25
|
+
const NEED_BASED_FILES = ["CODING_STANDARDS.md", "GLOSSARY.md"];
|
|
26
|
+
const STANDARDS_FILE = "CODING_STANDARDS.md";
|
|
27
|
+
/** A sentence shorter than this is a heading-like fragment, not a rule. */
|
|
28
|
+
const MIN_RULE_WORDS = 6;
|
|
29
|
+
|
|
30
|
+
type KnowledgeRule = "agents-budget" | "broken-link" | "scaffold-file" | "duplicate-rule";
|
|
31
|
+
|
|
32
|
+
type KnowledgeFinding = {
|
|
33
|
+
rule: KnowledgeRule;
|
|
34
|
+
/** Repository-relative, forward slashes. */
|
|
35
|
+
file: string;
|
|
36
|
+
line: number | null;
|
|
37
|
+
message: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type KnowledgeReport = {
|
|
41
|
+
root: string;
|
|
42
|
+
budget: number;
|
|
43
|
+
files: Array<{ file: string; bytes: number }>;
|
|
44
|
+
findings: KnowledgeFinding[];
|
|
45
|
+
/** Knowledge files that exist but could not be read (permissions, broken links). */
|
|
46
|
+
unreadable: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type Doc = { file: string; text: string; lines: string[] };
|
|
50
|
+
|
|
51
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
52
|
+
const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
|
|
53
|
+
const TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
|
|
54
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
55
|
+
const PLACEHOLDER =
|
|
56
|
+
/^(?:(?:tbd|todo|tba|fixme)\b.*|n\/a|none|none yet|coming soon|placeholder|\.\.\.|…|-)?[.:]?$/i;
|
|
57
|
+
|
|
58
|
+
const INDENTED_CODE = /^(?: {4}|\t)/;
|
|
59
|
+
const blank = (line: string | undefined) => (line ?? "").trim() === "";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Line indexes outside code (fenced blocks, closed only by a fence of the same
|
|
63
|
+
* character and at least the same length; 4-space indented blocks that do not
|
|
64
|
+
* continue a list), with HTML comments blanked.
|
|
65
|
+
*/
|
|
66
|
+
const proseLines = (lines: string[]): Array<{ index: number; text: string }> => {
|
|
67
|
+
const out: Array<{ index: number; text: string }> = [];
|
|
68
|
+
let fence: string | null = null;
|
|
69
|
+
let comment = false;
|
|
70
|
+
let indentedCode = false;
|
|
71
|
+
let inList = false;
|
|
72
|
+
lines.forEach((raw, index) => {
|
|
73
|
+
if (fence) {
|
|
74
|
+
const close = FENCE.exec(raw);
|
|
75
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length && !close[2].trim())
|
|
76
|
+
fence = null;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!comment) {
|
|
80
|
+
const open = FENCE.exec(raw);
|
|
81
|
+
if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
|
|
82
|
+
fence = open[1];
|
|
83
|
+
indentedCode = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (INDENTED_CODE.test(raw) && !blank(raw)) {
|
|
87
|
+
if (indentedCode || (!inList && blank(lines[index - 1]))) {
|
|
88
|
+
indentedCode = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
} else if (!blank(raw)) {
|
|
92
|
+
indentedCode = false;
|
|
93
|
+
inList = LIST_ITEM.test(raw) || (inList && /^\s/.test(raw));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let text = raw;
|
|
97
|
+
if (comment) {
|
|
98
|
+
const end = text.indexOf("-->");
|
|
99
|
+
if (end === -1) return;
|
|
100
|
+
comment = false;
|
|
101
|
+
text = text.slice(end + 3);
|
|
102
|
+
}
|
|
103
|
+
text = text.replace(/<!--[\s\S]*?-->/g, "");
|
|
104
|
+
const open = text.indexOf("<!--");
|
|
105
|
+
if (open !== -1) {
|
|
106
|
+
comment = true;
|
|
107
|
+
text = text.slice(0, open);
|
|
108
|
+
}
|
|
109
|
+
out.push({ index, text });
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const withoutFrontMatter = (lines: string[]): string[] => {
|
|
115
|
+
if (lines[0]?.trim() !== "---") return lines;
|
|
116
|
+
const end = lines.indexOf("---", 1);
|
|
117
|
+
return end === -1 ? lines : [...lines.slice(0, end + 1).map(() => ""), ...lines.slice(end + 1)];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The file as a Doc; null when absent; "unreadable" when present but it cannot be read. */
|
|
121
|
+
const read = (root: string, file: string): (Doc & { real: string }) | null | "unreadable" => {
|
|
122
|
+
const full = path.join(root, file);
|
|
123
|
+
try {
|
|
124
|
+
fs.lstatSync(full);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (!fs.statSync(full).isFile()) return null;
|
|
130
|
+
const text = fs.readFileSync(full, "utf8");
|
|
131
|
+
return { file, text, lines: text.split(/\r?\n/), real: fs.realpathSync(full) };
|
|
132
|
+
} catch {
|
|
133
|
+
return "unreadable";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Top-level entries git tracks, so a pointer is judged the same locally and in
|
|
139
|
+
* CI; null outside a git repository (then the file system decides).
|
|
140
|
+
*/
|
|
141
|
+
const trackedTopLevel = (root: string): Set<string> | null => {
|
|
142
|
+
const run = spawnSync("git", ["ls-files", "-z", "--cached"], {
|
|
143
|
+
cwd: root,
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
146
|
+
timeout: 20_000,
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
});
|
|
149
|
+
if (run.status !== 0) return null;
|
|
150
|
+
return new Set(
|
|
151
|
+
run.stdout
|
|
152
|
+
.split("\0")
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.map((file) => file.split("/")[0]),
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const exists = (target: string): boolean => {
|
|
159
|
+
try {
|
|
160
|
+
fs.statSync(target);
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// --- broken-link -----------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
// Targets: `<anything>` or a run without spaces that may hold balanced parentheses.
|
|
170
|
+
const LINK = /!?\[[^\]]*\]\(\s*(?:<([^>\n]+)>|((?:[^()\s<>]|\([^()\s]*\))+))(?:\s+["'(][^)]*)?\)/g;
|
|
171
|
+
const REFERENCE_DEFINITION = /^\s{0,3}\[[^\]]+\]:\s*<?(\S+?)>?(?:\s|$)/;
|
|
172
|
+
const CODE_SPAN = /`([^`\n]+)`/g;
|
|
173
|
+
const PATH_LIKE = /^(?:\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)+\/?$/;
|
|
174
|
+
|
|
175
|
+
const isExternal = (target: string) =>
|
|
176
|
+
/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#") || target.startsWith("//");
|
|
177
|
+
|
|
178
|
+
const localTarget = (target: string): string => {
|
|
179
|
+
const bare = target.replace(/[#?].*$/, "");
|
|
180
|
+
try {
|
|
181
|
+
return decodeURIComponent(bare);
|
|
182
|
+
} catch {
|
|
183
|
+
return bare;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const brokenLinks = (root: string, doc: Doc, tracked: Set<string> | null): KnowledgeFinding[] => {
|
|
188
|
+
const findings: KnowledgeFinding[] = [];
|
|
189
|
+
const base = path.dirname(path.join(root, doc.file));
|
|
190
|
+
const missing = (target: string, line: number, how: string) =>
|
|
191
|
+
findings.push({
|
|
192
|
+
rule: "broken-link",
|
|
193
|
+
file: doc.file,
|
|
194
|
+
line,
|
|
195
|
+
message: `${how} \`${target}\` points at a missing file`,
|
|
196
|
+
});
|
|
197
|
+
for (const { index, text } of proseLines(doc.lines)) {
|
|
198
|
+
// Links inside inline code are examples, not links.
|
|
199
|
+
const prose = text.replace(CODE_SPAN, (span) => " ".repeat(span.length));
|
|
200
|
+
const targets = [...prose.matchAll(LINK)].map((match) => match[1] ?? match[2]);
|
|
201
|
+
const definition = REFERENCE_DEFINITION.exec(prose)?.[1];
|
|
202
|
+
if (definition) targets.push(definition);
|
|
203
|
+
for (const target of targets) {
|
|
204
|
+
if (isExternal(target)) continue;
|
|
205
|
+
const local = localTarget(target);
|
|
206
|
+
if (!local) continue;
|
|
207
|
+
const resolved = local.startsWith("/") ? path.join(root, local) : path.resolve(base, local);
|
|
208
|
+
if (!exists(resolved)) missing(target, index + 1, "link");
|
|
209
|
+
}
|
|
210
|
+
// A backticked path counts as a pointer only when it is explicitly
|
|
211
|
+
// relative (./ or ../) or its first segment is a tracked top-level entry
|
|
212
|
+
// (dot-directories included), so commands, globs, placeholders, package
|
|
213
|
+
// names and local-only directories are never mistaken for one.
|
|
214
|
+
for (const [, span] of text.matchAll(CODE_SPAN)) {
|
|
215
|
+
const candidate = span.replace(/:\d+(?::\d+)?$/, "");
|
|
216
|
+
if (!PATH_LIKE.test(candidate)) continue;
|
|
217
|
+
const relative = /^\.\.?\//.test(candidate);
|
|
218
|
+
const first = candidate.split("/")[0];
|
|
219
|
+
if (!relative && !(tracked ? tracked.has(first) : exists(path.join(root, first)))) continue;
|
|
220
|
+
const resolved = relative ? path.resolve(base, candidate) : path.join(root, candidate);
|
|
221
|
+
if (!exists(resolved)) missing(span, index + 1, "pointer");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return findings;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// --- scaffold-file ---------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
const isPlaceholder = (text: string) =>
|
|
230
|
+
PLACEHOLDER.test(
|
|
231
|
+
text
|
|
232
|
+
.replace(LIST_ITEM, "")
|
|
233
|
+
.replace(/[*_`]/g, "")
|
|
234
|
+
.replace(/^\[\s?\]\s*/, "")
|
|
235
|
+
.trim(),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
/** True when the file has no line that is an entry: only headings, rules, comments, table headers or placeholders. */
|
|
239
|
+
const scaffoldOnly = (doc: Doc): boolean => {
|
|
240
|
+
const lines = proseLines(withoutFrontMatter(doc.lines));
|
|
241
|
+
return lines.every(({ text }, position) => {
|
|
242
|
+
const trimmed = text.trim();
|
|
243
|
+
if (trimmed === "" || HEADING.test(text) || /^([-*_])(\s*\1){2,}$/.test(trimmed)) return true;
|
|
244
|
+
if (TABLE_SEPARATOR.test(text)) return true;
|
|
245
|
+
const next = lines[position + 1]?.text ?? "";
|
|
246
|
+
if (trimmed.startsWith("|") && TABLE_SEPARATOR.test(next)) return true;
|
|
247
|
+
if (trimmed.startsWith("|"))
|
|
248
|
+
return trimmed
|
|
249
|
+
.split("|")
|
|
250
|
+
.map((cell) => cell.trim())
|
|
251
|
+
.every((cell) => cell === "" || isPlaceholder(cell));
|
|
252
|
+
return isPlaceholder(trimmed);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// --- duplicate-rule --------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/** Unwrapped list items and paragraphs, each with the line it starts on. */
|
|
259
|
+
const ruleUnits = (doc: Doc): Array<{ line: number; text: string }> => {
|
|
260
|
+
const units: Array<{ line: number; text: string }> = [];
|
|
261
|
+
let current: { line: number; text: string } | null = null;
|
|
262
|
+
const flush = () => {
|
|
263
|
+
if (current) units.push(current);
|
|
264
|
+
current = null;
|
|
265
|
+
};
|
|
266
|
+
for (const { index, text } of proseLines(doc.lines)) {
|
|
267
|
+
const trimmed = text.trim();
|
|
268
|
+
if (trimmed === "" || HEADING.test(text) || trimmed.startsWith("|")) {
|
|
269
|
+
flush();
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (LIST_ITEM.test(text) || !current) {
|
|
273
|
+
flush();
|
|
274
|
+
current = { line: index + 1, text: trimmed.replace(LIST_ITEM, "") };
|
|
275
|
+
} else current.text += ` ${trimmed}`;
|
|
276
|
+
}
|
|
277
|
+
flush();
|
|
278
|
+
return units;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const normalize = (sentence: string): string =>
|
|
282
|
+
sentence
|
|
283
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
284
|
+
.replace(/[*_`]/g, "")
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.replace(/\s+/g, " ")
|
|
287
|
+
.replace(/[\s.,;:!?]+$/, "")
|
|
288
|
+
.trim();
|
|
289
|
+
|
|
290
|
+
const sentences = (doc: Doc): Map<string, number> => {
|
|
291
|
+
const out = new Map<string, number>();
|
|
292
|
+
for (const unit of ruleUnits(doc))
|
|
293
|
+
for (const sentence of unit.text.split(/(?<=[.!?])\s+/)) {
|
|
294
|
+
const key = normalize(sentence);
|
|
295
|
+
if (key.split(" ").length >= MIN_RULE_WORDS && !out.has(key)) out.set(key, unit.line);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const duplicateRules = (steering: Doc[], standards: Doc): KnowledgeFinding[] => {
|
|
301
|
+
const findings: KnowledgeFinding[] = [];
|
|
302
|
+
const rules = sentences(standards);
|
|
303
|
+
for (const doc of steering)
|
|
304
|
+
for (const [key, line] of sentences(doc)) {
|
|
305
|
+
const standardsLine = rules.get(key);
|
|
306
|
+
if (standardsLine === undefined) continue;
|
|
307
|
+
findings.push({
|
|
308
|
+
rule: "duplicate-rule",
|
|
309
|
+
file: doc.file,
|
|
310
|
+
line,
|
|
311
|
+
message: `repeats ${standards.file}:${standardsLine} ("${key.slice(0, 60)}${key.length > 60 ? "…" : ""}"); keep the rule in one file`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return findings;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// --- entry -----------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/** Lint the knowledge files at `root` (the repository top). */
|
|
320
|
+
export function lintKnowledge(root: string): KnowledgeReport {
|
|
321
|
+
const seen = new Set<string>();
|
|
322
|
+
const unreadable: string[] = [];
|
|
323
|
+
const load = (file: string): Doc | null => {
|
|
324
|
+
const doc = read(root, file);
|
|
325
|
+
if (doc === "unreadable") unreadable.push(file);
|
|
326
|
+
if (doc === null || doc === "unreadable" || seen.has(doc.real)) return null;
|
|
327
|
+
seen.add(doc.real);
|
|
328
|
+
return doc;
|
|
329
|
+
};
|
|
330
|
+
const steering = STEERING_FILES.map(load).filter((doc): doc is Doc => doc !== null);
|
|
331
|
+
const needBased = NEED_BASED_FILES.map(load).filter((doc): doc is Doc => doc !== null);
|
|
332
|
+
const findings: KnowledgeFinding[] = [];
|
|
333
|
+
|
|
334
|
+
for (const doc of steering) {
|
|
335
|
+
const bytes = Buffer.byteLength(doc.text);
|
|
336
|
+
if (bytes > AGENTS_BYTE_BUDGET)
|
|
337
|
+
findings.push({
|
|
338
|
+
rule: "agents-budget",
|
|
339
|
+
file: doc.file,
|
|
340
|
+
line: null,
|
|
341
|
+
message: `${bytes} bytes, over the ${AGENTS_BYTE_BUDGET}-byte budget; move detail behind a pointer or into a check, and name what each addition removes`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
for (const doc of needBased)
|
|
345
|
+
if (scaffoldOnly(doc))
|
|
346
|
+
findings.push({
|
|
347
|
+
rule: "scaffold-file",
|
|
348
|
+
file: doc.file,
|
|
349
|
+
line: null,
|
|
350
|
+
message:
|
|
351
|
+
"holds no entry (headings, comments or placeholders only); delete it, or create it in the same edit as its first real entry",
|
|
352
|
+
});
|
|
353
|
+
const tracked = trackedTopLevel(root);
|
|
354
|
+
for (const doc of [...steering, ...needBased]) findings.push(...brokenLinks(root, doc, tracked));
|
|
355
|
+
const standards = needBased.find((doc) => doc.file === STANDARDS_FILE);
|
|
356
|
+
if (standards) findings.push(...duplicateRules(steering, standards));
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
root,
|
|
360
|
+
budget: AGENTS_BYTE_BUDGET,
|
|
361
|
+
files: [...steering, ...needBased].map((doc) => ({
|
|
362
|
+
file: doc.file,
|
|
363
|
+
bytes: Buffer.byteLength(doc.text),
|
|
364
|
+
})),
|
|
365
|
+
findings,
|
|
366
|
+
unreadable,
|
|
367
|
+
};
|
|
368
|
+
}
|