@brainervirus/workit-core 7.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "7.3.0",
3
+ "version": "7.4.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: workit-retro
3
+ description: Find repeated friction in recent sessions; propose cited fixes ranked by enforcer strength, never applied. Use for retro.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Retro: turn repeated friction into enforcers
8
+
9
+ User-invoked: offer it, never start it yourself. Run it after a session, PR,
10
+ stack or fan-in, including ones that went well; a smooth session still shows
11
+ where agents searched too long, worked around a tool or re-ran a check. Retro
12
+ proposes and stops. Nothing changes until the user approves.
13
+
14
+ 1. **Scope.** Default: this repository's work since the last retro (its
15
+ `retro:` row in `workit ledger list --type decision`), else the last ~10
16
+ sessions. State the window in one line.
17
+ 2. **Read through workit, cheapest first.** Every finding cites these:
18
+ - `workit ledger list --last 200`: rulings (ambiguities the agent had to
19
+ settle), failed or self verdicts, handoffs, check runs, CI reruns.
20
+ - `workit pr status --pr <n>` for each recent PR: threads, failing checks.
21
+ - `git log --oneline -50`, plus reverts and fixups after review.
22
+ - `workit knowledge lint`: today's AGENTS.md and need-based files.
23
+ - Session transcripts **only if the user opts in**, and only this
24
+ workspace's (`references/sources.md`). Never read other projects.
25
+
26
+ Cite by location (ledger row, PR, commit, session file and line). Never
27
+ copy secrets, tokens or personal data from any source into the report.
28
+ 3. **Group into classes.** Navigation cost (many searches before the right
29
+ file, a stale doc followed), repeated mistakes, workarounds (a hand-run
30
+ command where a verb exists, a skipped step), unstable checks (the same
31
+ `workit check <name>` red then green with no change). A class needs **2 or
32
+ more cited occurrences**; a one-off is not a learning.
33
+ 4. **Name the strongest enforcer that works**, strongest first: architecture
34
+ or types (the mistake cannot be written) > lint rule > configured check
35
+ (`workit.checks.json`, `workit check <name>`) or CI job > test >
36
+ `CODING_STANDARDS.md` (judgment the reviewer reads) > AGENTS.md pointer
37
+ (navigation only). A mechanical rule gets a check, not prose: a check can
38
+ fail, a sentence cannot. From types to test, the proof is that the new
39
+ enforcer fails on the cited past mistake. A rule whose mistake can no
40
+ longer happen is deleted.
41
+ 5. **Upstream.** When a workit skill, verb or hook caused the friction,
42
+ propose an issue or PR on BrainerVirus/workit. Never fork a local copy.
43
+ The issue shows the workit behavior in a minimal synthetic reproduction:
44
+ no private repo name, path, code, ledger text, PR or thread quote, or
45
+ transcript. Show the exact draft body; file it only after the user approves.
46
+ 6. **Bloat guards.** AGENTS.md stays within 8 KB: each addition names what it
47
+ removes. Create `CODING_STANDARDS.md` or `GLOSSARY.md` only in the same
48
+ edit as its first real entry, never as a scaffold. Edit steering text by
49
+ `references/steering.md`; `workit knowledge lint` passes after the slice.
50
+ 7. **Report one ranked list, then stop:** Accepted (proposed), Backlog,
51
+ Dropped, each with its citations, enforcer and reason. Each approved item
52
+ becomes a normal slice (workit-implement, then workit-ship), a tracker
53
+ issue, or `.out-of-scope/<concept>.md` when rejected and likely to return.
54
+ Record the user's answer so the next retro starts there:
55
+ `workit ledger decision "retro: <accepted ids>" --why "<window>"`.
56
+
57
+ ## Example
58
+
59
+ Bad: "Agents seem lost in the build. Added 'read the build docs carefully' to
60
+ AGENTS.md." One vague occurrence, no citation, a no-op line, auto-applied.
61
+
62
+ Good: "Navigation, 3 occurrences (rulings 12, 19; PR #88 thread): agents sought
63
+ check names in package.json, missing `workit.checks.json`. Enforcer: AGENTS.md
64
+ pointer, +74 bytes, minus the stale Commands paragraph (-210). Unstable check,
65
+ 2 occurrences (ledger rows 31, 44: `e2e` red then green on one SHA): a debug
66
+ slice to fix the check. Approve?"
67
+
68
+ ## Check
69
+
70
+ ```sh
71
+ workit knowledge lint # exit 0 before and after each approved slice
72
+ ```
@@ -0,0 +1,3 @@
1
+ # Codex: user-invoked only (the counterpart of disable-model-invocation).
2
+ policy:
3
+ allow_implicit_invocation: false
@@ -0,0 +1,35 @@
1
+ # Session transcripts (opt-in, this workspace only)
2
+
3
+ Transcripts are the most expensive source and can hold private material. Read
4
+ them only after the user says yes to a question such as "Also read the last 10
5
+ session transcripts for this workspace? (local files, read-only)". The default
6
+ is no: the ledger, `pr status` and git are enough for most retros.
7
+
8
+ ## Where they live
9
+
10
+ Typical locations; confirm each exists. Resolve the path from this workspace's
11
+ absolute path, never with a wildcard across projects. If a host's layout
12
+ differs from the one below, say so and skip it rather than search wider.
13
+
14
+ | Host | Location |
15
+ | --- | --- |
16
+ | Claude Code | `~/.claude/projects/<folder>/*.jsonl`, one folder per path from `git worktree list`: the absolute path with every `/` and `.` replaced by `-`. Compute each folder name; never glob for it |
17
+ | Codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. To filter, parse only line 1 (`session_meta`) and read only its `cwd` field; skip files whose `cwd` is not this workspace without reading or reporting anything else from them |
18
+ | Cursor | `~/.cursor/projects/<workspace slug>/agent-transcripts/` |
19
+ | Pi | `~/.pi/agent/sessions/--<workspace path, / replaced by ->--/*.jsonl` |
20
+ | OpenCode | this project's sessions from `opencode session list`, read with `opencode export <id>` |
21
+
22
+ Linked worktrees of the same repository count as this workspace; other
23
+ repositories never do.
24
+
25
+ ## How to read them
26
+
27
+ - Newest first, at most the agreed number of sessions.
28
+ - On a host with subagents, one read-only analyst per lens, each returning
29
+ only cited occurrences (session file plus line or message index):
30
+ - navigation: searches and reads before the right file was found, stale docs
31
+ followed;
32
+ - tool economy: hand-run commands where a `workit` verb exists, repeated
33
+ reads of the same file, long outputs nobody used;
34
+ - repeated work: the same fix or check redone, a step undone later;
35
+ - request conflicts: instructions the agent had to reconcile or ask about.
@@ -0,0 +1,39 @@
1
+ # Editing steering text (AGENTS.md, CODING_STANDARDS.md, skills)
2
+
3
+ Steering text is paid for on every session that reads it. Each line must
4
+ change what an agent does; everything else is cost.
5
+
6
+ ## Write
7
+
8
+ - **Point, do not copy.** Link the file or name the command; never paste its
9
+ content. A pointer says when to follow it: "Read before editing a hook:
10
+ `docs/agents/hosts.md`".
11
+ - **Say what done looks like.** "Run `bun run check`; it must exit 0" beats
12
+ "make sure everything works".
13
+ - **Concrete over adjectives.** A command, a path, a number. Prefer the action
14
+ to take over a list of things to avoid.
15
+ - **One rule, one home.** A rule with an enforcer (type, lint, check, test)
16
+ appears in steering text at most as a pointer to that enforcer. The same
17
+ sentence in AGENTS.md and CODING_STANDARDS.md is a defect
18
+ (`duplicate-rule`).
19
+ - **Judgment goes to CODING_STANDARDS.md**, which the reviewer reads; only
20
+ navigation goes to AGENTS.md.
21
+
22
+ ## Cut
23
+
24
+ - **No-ops.** Delete lines that would not change any behavior if removed:
25
+ "be thorough", "write clean code", "keep it concise", "follow best
26
+ practices".
27
+ - **Session residue.** No notes about one session, no implementation detail
28
+ the code already shows, no `path:line` references (they go stale).
29
+ - **Dead rules.** A rule whose mistake can no longer happen (a type or check
30
+ now prevents it) is deleted, not kept "for context".
31
+
32
+ ## Budget
33
+
34
+ - AGENTS.md stays within 8 KB (`workit knowledge lint`, rule
35
+ `agents-budget`). A proposal that adds text names what it removes, or shows
36
+ the byte count still holds.
37
+ - A need-based file (`CODING_STANDARDS.md`, `GLOSSARY.md`) is created in the
38
+ same edit as its first real entry. Headers-only or "TBD" files fail the lint
39
+ (`scaffold-file`).
@@ -18,8 +18,9 @@ independent; `type-check-only` never proves a behavior change.
18
18
  3. Judge two axes separately, never merged or re-ranked:
19
19
  - **Spec:** does the diff do what the acceptance says? Missing, creep or
20
20
  wrong; quote the line.
21
- - **Standards:** repo rules first, then a smell baseline (unclear name, long
22
- function, duplicated logic, leaky abstraction). Judgment only; lint owns nits.
21
+ - **Standards:** repo rules first (`CODING_STANDARDS.md` when present),
22
+ then a smell baseline (unclear name, long function, duplicated logic,
23
+ leaky abstraction). Judgment only; lint owns nits.
23
24
  4. **Tests:** `workit test-audit --diff`. Would each new test fail if the
24
25
  behavior broke? Triage with workit-test-audit.
25
26
  5. **Blast radius:** for each touched contract, caller, config or migration,
@@ -10,11 +10,14 @@ user asks for one, say which trigger fired, and let the user decline.
10
10
  | Plan | more than one slice with dependencies, or work that will be resumed by someone else | `docs/<topic>/plan.md`, next to the spec |
11
11
  | ADR | the choice is hard to reverse **and** surprising **and** a real trade-off (all three) | `docs/adr/NNNN-<slug>.md` |
12
12
  | Glossary entry | a project term was ambiguous and you resolved it | `GLOSSARY.md` (create lazily) |
13
+ | Coding standard | a judgment-call rule a reviewer must check, recurring twice (workit-retro); a mechanical rule gets a check instead | `CODING_STANDARDS.md` (create lazily) |
13
14
  | Out of scope | a request was rejected and is likely to come back | `.out-of-scope/<concept>.md` |
14
15
 
15
16
  Never: a spec for a one-file mechanical fix, a plan that restates the spec,
16
17
  file paths or line numbers in a spec (they go stale), a glossary entry for a
17
18
  general programming term.
19
+ Create a lazy file only in the same edit as its first real entry, never as a
20
+ headers-only or TBD scaffold (`workit knowledge lint` flags one).
18
21
 
19
22
  ## Spec (scaled to the work)
20
23
 
@@ -53,7 +53,8 @@ the base keeps moving, or after 3 failed fix attempts on the same check.
53
53
  6. **Verified.** After the last push a non-author records a verdict
54
54
  (workit-review); `pr status` showing self-reviewed is not verified. Land only when granted: `workit stack land` (the
55
55
  contiguous verified run from the root) or `workit pr merge`.
56
- 7. **Observe it landed:** `workit verify-delivery pr` or `merge`.
56
+ 7. **Observe it landed:** `workit verify-delivery pr` or `merge`. At any endpoint
57
+ (stack land, fan-in too), after a failed verdict or a check red 3+ times: offer `/wk-retro`.
57
58
 
58
59
  ## Example
59
60
 
@@ -89,7 +89,7 @@ export function selectMethods(policy: Policy, capabilities: Capability[]): Selec
89
89
  export const invariantBootstrap = (): string =>
90
90
  `
91
91
  Workit is optional coordination and proof tooling around the host: you decide
92
- what and whether, the \`workit\` CLI does how and records what it observed.
92
+ what and whether, the CLI does how and records what it observed.
93
93
  Native host allow/ask/deny, sandbox and org rules stay authoritative. Never
94
94
  switch paths to evade a denial, never claim enforcement a host cannot provide,
95
95
  and never fabricate an approval or verdict. A question answer is not
@@ -110,8 +110,8 @@ Autonomy contract:
110
110
  never edit workspaces.json or route around a grant_required.
111
111
  - Ask only for a product or preference choice, or for authority you lack, with
112
112
  your recommended answer. Facts are yours: read, run or prototype.
113
- - Label claims measured (you ran it this session and saw the result),
114
- inferred, or guess. Prose is a note: only a configured check the CLI ran
113
+ - Label claims measured (you ran it this session and saw it), inferred, or
114
+ guess. Prose is a note: only a configured check the CLI ran
115
115
  (\`workit check <name>\`) satisfies a gate; an ad-hoc
116
116
  \`workit check -- <cmd>\` never does.
117
117
  - Author is not verifier: your own verdict is \`--self\` (after an observed
@@ -123,23 +123,21 @@ Autonomy contract:
123
123
  reconcile every requested item and observe it (\`workit verify-delivery\`).
124
124
 
125
125
  CLI first: if a step has one right answer, use the \`workit\` verb instead of
126
- hand-running git, gh or glab: check, git, pr, ci, stack, verify-delivery,
127
- ledger, handoff, test-audit (\`workit help <verb>\`; without \`workit\` on
128
- PATH, \`npx -y @brainervirus/workit-cli\`). \`busy\` is retryable
129
- (\`workit doctor --fix-lock\` clears a dead lock); \`blocked\` names its unblock.
126
+ hand-running git, gh or glab (\`workit git|pr|ci|check\`; \`workit help\` lists
127
+ the rest; without \`workit\` on PATH, \`npx -y @brainervirus/workit-cli\`).
128
+ \`busy\` is retryable (\`workit doctor --fix-lock\` clears a dead lock).
130
129
  The task families (task, policy, evidence, finding, decision, worker, state)
131
130
  are optional continuity for tracked work; each branch has one implicit task
132
131
  that the first recording creates, and a solo edit needs no task. A decision
133
132
  records a user choice; it never authorizes an effect. Judge work once with
134
133
  \`workit policy assess --judge risk= behavior= product-choice= plan=\`; an
135
134
  open choice or missing plan blocks edits until recorded.
136
- Omit revisions; a revision_conflict means one you passed is stale, so re-read.
135
+ Omit revisions; on a revision_conflict, re-read.
137
136
  Across repositories, bind each item to its checkout, branch and endpoint,
138
137
  resolve competing targets before a mutation, and reconcile an uncertain
139
138
  external effect before retrying it.
140
139
 
141
- Mid-task input: answer a quick question in place, fold an adjustment in, and
142
- never silently drop or resume an objective (workit-continue).
140
+ Mid-task: never silently drop or resume an objective (workit-continue).
143
141
 
144
142
  Skills: load the skill, never act from memory of it (slash aliases /wk-<name>).
145
143
  - brainstorm, plan, spec, grill, should we: workit-shape
@@ -153,4 +151,5 @@ Skills: load the skill, never act from memory of it (slash aliases /wk-<name>).
153
151
  - deslop, slop, dead code: workit-deslop
154
152
  - fan out, parallelize, parallel agents, swarm: workit-fanout
155
153
  - verify the app, smoke test, prove it works: workit-verify-app
154
+ - retro (user-invoked; offer it): workit-retro
156
155
  `.trim();
@@ -13,6 +13,7 @@ export const WORKIT_METHOD_SKILLS = [
13
13
  "workit-deslop",
14
14
  "workit-fanout",
15
15
  "workit-verify-app",
16
+ "workit-retro",
16
17
  ] as const;
17
18
 
18
19
  export type WorkitSkill = (typeof WORKIT_METHOD_SKILLS)[number];
@@ -31,6 +32,7 @@ export const WORKIT_SKILL_ALIASES = {
31
32
  "wk-deslop": "workit-deslop",
32
33
  "wk-fanout": "workit-fanout",
33
34
  "wk-verify-app": "workit-verify-app",
35
+ "wk-retro": "workit-retro",
34
36
  } as const satisfies Record<string, WorkitSkill>;
35
37
 
36
38
  /**
@@ -50,6 +52,7 @@ export const WORKIT_SKILL_TRIGGERS: Readonly<Record<WorkitSkill, readonly string
50
52
  "workit-deslop": ["deslop", "slop", "dead code"],
51
53
  "workit-fanout": ["fan out", "parallelize", "parallel agents", "swarm"],
52
54
  "workit-verify-app": ["verify the app", "smoke test", "prove it works"],
55
+ "workit-retro": ["retro"],
53
56
  };
54
57
 
55
58
  /** The `description:` frontmatter value of a SKILL.md (one line, unquoted). */
@@ -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
+ }