@erclx/aitk 0.56.0 → 0.58.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-groundwork/SKILL.md +4 -14
- package/claude/skills/claude-intake/SKILL.md +5 -37
- package/docs/agents/scripting.md +21 -9
- package/governance/rules/claude/556-groundwork.md +16 -0
- package/governance/rules/claude/557-intake.md +16 -0
- package/governance/stacks/base.toml +3 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +25 -5
- package/scripts/core/verify.sh +55 -0
- package/src/commands/gov.ts +79 -4
- package/src/gov/install.ts +32 -15
- package/src/gov/list.ts +106 -0
- package/src/gov/stacks.ts +55 -6
- package/standards/groundwork.md +199 -0
- package/standards/index.md +2 -0
- package/{claude/skills/claude-intake/references/folder-format.md → standards/intake.md} +92 -9
- package/standards/rule.md +9 -0
- package/claude/skills/claude-groundwork/references/folder-format.md +0 -107
- package/scripts/gov/list.sh +0 -234
|
@@ -7,7 +7,7 @@ description: Opens and runs a numbered groundwork folder under `.claude/groundwo
|
|
|
7
7
|
|
|
8
8
|
Groundwork gathers and weighs. A plan commits. A groundwork folder costs nothing to throw away, which is what makes it the right container for a question nobody has answered yet.
|
|
9
9
|
|
|
10
|
-
Read
|
|
10
|
+
Read `.claude/standards/groundwork.md` from the project root before writing any file in the folder, or `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` when the project does not have it. It holds the reserved numbers, the frontmatter and dating rules, what each required file carries, the conventions, and the anti-patterns. Do not work them from memory.
|
|
11
11
|
|
|
12
12
|
## Guards
|
|
13
13
|
|
|
@@ -65,19 +65,9 @@ Then read only what a live question needs. Do not read entire directories specul
|
|
|
65
65
|
|
|
66
66
|
Do not dispatch subagents. A groundwork track is a conversation, and fanning out loses the reasoning that makes the folder worth keeping. A search too large to run inline is a finding that the question is too broad.
|
|
67
67
|
|
|
68
|
-
## Open
|
|
68
|
+
## Open questions in chat
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
```markdown
|
|
73
|
-
1. <question>
|
|
74
|
-
- Leaning: <where the evidence currently points>
|
|
75
|
-
- Overturned by: <the finding that would change it>
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
- `Leaning:` is weaker than a plan's `- Suggested:`. It records the current read on a question still open by definition, not a decision to accept by default at execution time.
|
|
79
|
-
- Pair every lean with what would overturn it. A lean with no falsifier is an opinion.
|
|
80
|
-
- On a measurement rather than a judgment, write `- Leaning: none, needs measuring` and drop the overturn line. A guess at a number is worse than an admission.
|
|
70
|
+
The standard sets the open question format and requires it inside a topic file and in `00-scope.md`. Carry the same shape into the chat output, which sits outside the folder the standard governs. A bare numbered list hands the reader a quiz and defers the judgment the track exists to inform.
|
|
81
71
|
|
|
82
72
|
## Open mode
|
|
83
73
|
|
|
@@ -85,7 +75,7 @@ Every open question carries a lean, wherever it appears: inside a topic file, in
|
|
|
85
75
|
2. Write `README.md` first. Writing it first forces the question of what the track is for.
|
|
86
76
|
3. Write `01-current-state.md` by measuring now. Never carry a figure from a previous session or from recall without re-measuring it. Measure only what an open question in the folder needs. A number with no question attached is how groundwork turns into the work.
|
|
87
77
|
4. Write `00-scope.md` when the track is large enough to run away. Skip it on a small track.
|
|
88
|
-
5. Add topic files at `02` through `05` as the subject demands. Close each one with its open questions in the format
|
|
78
|
+
5. Add topic files at `02` through `05` as the subject demands. Close each one with its open questions in the standard's format.
|
|
89
79
|
6. Keep going. Revise, reframe, and take correction as the questions move. The folder is meant to be rewritten.
|
|
90
80
|
|
|
91
81
|
## Resume mode
|
|
@@ -7,7 +7,7 @@ description: Files a raw brain dump into a numbered intake folder under `.claude
|
|
|
7
7
|
|
|
8
8
|
Intake dispositions many findings in breadth. A dump goes in, an inventory comes out, and every item carries a problem measured against the tree, one proposed fix, and a verdict. The item that turns out to be already settled is the highest-value output, and it is the one thing neither a plan nor a groundwork track has anywhere to put.
|
|
9
9
|
|
|
10
|
-
Read
|
|
10
|
+
Read `.claude/standards/intake.md` from the project root before writing any file in the folder, or `${CLAUDE_SKILL_DIR}/../../standards/intake.md` when the project does not have it. It holds the numbering, the file map, the frontmatter and dating rules, the item format, and the answer contract. Do not work them from memory.
|
|
11
11
|
|
|
12
12
|
## Routing
|
|
13
13
|
|
|
@@ -34,7 +34,7 @@ Using the wrong one fails in two shapes. Intake on a question that needs measuri
|
|
|
34
34
|
- Reading is unrestricted inside the project. Measuring is the work.
|
|
35
35
|
- Treat the folder as gitignored and unbacked. No check reaches its contents, so every rule stated here holds only while a session reads it.
|
|
36
36
|
|
|
37
|
-
Nothing outside this body carries the floor, and no path-scoped rule can.
|
|
37
|
+
Nothing outside this body carries the write-scope floor, and no path-scoped rule can. A misrouted write lands on a path the folder's glob never matches, so the rule that ships beside the intake standard carries the item format and the answer contract instead, for a session editing the folder with this skill unloaded.
|
|
38
38
|
|
|
39
39
|
## Step 1: detect open or resume
|
|
40
40
|
|
|
@@ -71,7 +71,9 @@ Two heading levels is the right depth. A third means the cluster should have bee
|
|
|
71
71
|
|
|
72
72
|
## Step 5: disposition each item
|
|
73
73
|
|
|
74
|
-
Write every item in the format
|
|
74
|
+
Write every item in the standard's item format, in the cluster file its fix belongs to. Close each item with a verdict and an empty operator slot, and never fill that slot or read an empty one as agreement.
|
|
75
|
+
|
|
76
|
+
Where an item's call is the operator's, pair it with a suggestion rather than a bare question. The toolkit's `decision-help` snippet writes the same shape for chat use, and the standard's item format is the whole spec.
|
|
75
77
|
|
|
76
78
|
## Step 6: write the index
|
|
77
79
|
|
|
@@ -81,40 +83,6 @@ Each open question in the index is a labeled markdown link to its owning item's
|
|
|
81
83
|
|
|
82
84
|
Add `99-next-session.md` only where the pass ends holding context no cluster file carries, such as a dump half filed or a measurement that has to be redone. Write it self-contained, since the folder is unbacked and the conversation behind it compacts away.
|
|
83
85
|
|
|
84
|
-
## Item format
|
|
85
|
-
|
|
86
|
-
```markdown
|
|
87
|
-
### N. Short title stating the defect
|
|
88
|
-
|
|
89
|
-
- **Problem:** what is wrong today, stated against the tree and carrying a number or a file path
|
|
90
|
-
- **Fix:** the one change proposed
|
|
91
|
-
- **Worth it:** yes, later, or no, with the reason
|
|
92
|
-
- **Open:** only where the call is the operator's
|
|
93
|
-
- **Suggested:** the pick in one sentence, then the reason and the main tradeoff in one or two
|
|
94
|
-
- **Overlaps:** the live board task that already owns this item
|
|
95
|
-
- **You:**
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
- `Problem:`, `Fix:`, `Worth it:`, and the empty `You:` slot ship on every item. The other two are conditional.
|
|
99
|
-
- `Suggested:` is required whenever `Open:` is present. A bare question invites a bare answer, and `ok` against two defensible options carries no information. Where the answer is the operator's preference rather than a technical call, say so in that form rather than inventing a default. The toolkit's `decision-help` snippet writes the same four-line shape for chat use, and the four lines above are the whole spec.
|
|
100
|
-
- `Overlaps:` never replaces `Worth it:`. The items where a live board task might be the thing that is wrong are exactly the ones whose verdict matters most.
|
|
101
|
-
|
|
102
|
-
## The answer contract
|
|
103
|
-
|
|
104
|
-
`You:` belongs to the operator and ships empty on every item.
|
|
105
|
-
|
|
106
|
-
Empty means unread. It never means agreement. Accepting a verdict is typed as one token, `- **You:** ok`.
|
|
107
|
-
|
|
108
|
-
That inverts the plan file's contract, where a blank `- Answer:` means accept the suggestion, and the inversion is deliberate. A plan is read in one sitting with every question already surfaced in chat. An intake folder is read over weeks, so an empty slot is ambiguous between accepting the verdict and never having reached the item, and the second reading is far more likely. Acting on silence as consent ships a change nobody approved.
|
|
109
|
-
|
|
110
|
-
Never fill a `You:` slot, and never infer a disposition from an empty one. On a resume pass, report unread items by count rather than deciding them.
|
|
111
|
-
|
|
112
|
-
## Numbering
|
|
113
|
-
|
|
114
|
-
Numbers are read order and nothing else. Reserve `00` for the index and carry the domain in every other filename, so `07-tooling.md` says what it holds before anyone opens it.
|
|
115
|
-
|
|
116
|
-
Do not reserve mid-range numbers. Clusters differ per dump, so a contract over `06` would force every future intake into one dump's shape. `claude-groundwork` reserves its numbers because its shape is fixed, and that half of the convention does not transfer.
|
|
117
|
-
|
|
118
86
|
## Output
|
|
119
87
|
|
|
120
88
|
Emit the full relative path from the project root for every file written, and name the heading and the act beside it. A path alone cannot distinguish three new items from one reworded sentence in a file that holds a dozen items and lives for weeks, so a bare path sends the reader to diff it against memory. This overrides the paths-only reporting the project states generally, which stays right wherever the reader is about to see a diff.
|
package/docs/agents/scripting.md
CHANGED
|
@@ -11,15 +11,15 @@ What a skill or script reads to discover names at runtime, and how each domain i
|
|
|
11
11
|
|
|
12
12
|
Use these to discover what's available instead of hardcoding names.
|
|
13
13
|
|
|
14
|
-
| Command | Returns
|
|
15
|
-
| -------------------------------- |
|
|
16
|
-
| `aitk tooling list --json` | Stacks, extends chain, dep and script counts
|
|
17
|
-
| `aitk snippets list --json` | Presets and categories with their slugs
|
|
18
|
-
| `aitk standards list --json` | Standards docs and the paths each governs
|
|
19
|
-
| `aitk gov list --json` | Governance stacks and
|
|
20
|
-
| `aitk claude seeds list --json` | Seed doc sources with content
|
|
21
|
-
| `aitk claude skills list --json` | Plugin skills, descriptions, requirement flag
|
|
22
|
-
| `aitk docs list --json` | Consumer docs plus per-domain context
|
|
14
|
+
| Command | Returns |
|
|
15
|
+
| -------------------------------- | ---------------------------------------------------- |
|
|
16
|
+
| `aitk tooling list --json` | Stacks, extends chain, dep and script counts |
|
|
17
|
+
| `aitk snippets list --json` | Presets and categories with their slugs |
|
|
18
|
+
| `aitk standards list --json` | Standards docs and the paths each governs |
|
|
19
|
+
| `aitk gov list --json` | Governance stacks, rule sets, and unreferenced rules |
|
|
20
|
+
| `aitk claude seeds list --json` | Seed doc sources with content |
|
|
21
|
+
| `aitk claude skills list --json` | Plugin skills, descriptions, requirement flag |
|
|
22
|
+
| `aitk docs list --json` | Consumer docs plus per-domain context |
|
|
23
23
|
|
|
24
24
|
### Catalog fields
|
|
25
25
|
|
|
@@ -40,6 +40,18 @@ listing and the install cannot disagree. It now reports
|
|
|
40
40
|
`.claude/context/index.md`, which `init` has always installed and the listing
|
|
41
41
|
never named, and it emits the project-level `CLAUDE.md` last rather than first.
|
|
42
42
|
|
|
43
|
+
`aitk gov list` reports each stack's rules already expanded. A stack entry names
|
|
44
|
+
either a rule or a whole rule folder under `governance/rules/`, and the folder
|
|
45
|
+
form reaches this listing as the rules it stands for rather than as the folder
|
|
46
|
+
name. A consumer deduping `--add` extras against a stack therefore reads the same
|
|
47
|
+
set the install writes, whichever form the stack file uses.
|
|
48
|
+
|
|
49
|
+
It also carries `unreferenced`, the rules no stack reaches, on every invocation
|
|
50
|
+
rather than behind a flag. These install only through `--add`, so the key answers
|
|
51
|
+
what a stack leaves out without a caller resolving every stack itself. The key is
|
|
52
|
+
present alongside `stacks` and `rules` and survives `--stacks` or `--rules`
|
|
53
|
+
narrowing the other two.
|
|
54
|
+
|
|
43
55
|
### The skills catalog
|
|
44
56
|
|
|
45
57
|
`aitk claude skills list` reads `claude/skills/*/SKILL.md` and reports the folder
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Route .claude/groundwork/ edits to the groundwork standard for numbering, frontmatter, and required file contents
|
|
3
|
+
paths:
|
|
4
|
+
- '.claude/groundwork/**'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Groundwork standards
|
|
8
|
+
|
|
9
|
+
## Measuring and closing
|
|
10
|
+
|
|
11
|
+
- Never carry a figure from an earlier session without re-measuring it. Mark an inference as an inference.
|
|
12
|
+
- Never close a track while an open question quietly fails an outcome. Resolve it, or record it as knowingly accepted.
|
|
13
|
+
|
|
14
|
+
## Authority
|
|
15
|
+
|
|
16
|
+
- Follow `.claude/standards/groundwork.md` for reserved numbering, frontmatter and dating, what each required file holds, and the conventions a track keeps. It is the single source.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Route .claude/intake/ edits to the intake standard for the item format, the answer contract, and numbering
|
|
3
|
+
paths:
|
|
4
|
+
- '.claude/intake/**'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Intake standards
|
|
8
|
+
|
|
9
|
+
## The answer contract
|
|
10
|
+
|
|
11
|
+
- Never fill a `You:` slot and never infer a disposition from an empty one. Empty means unread, never agreement.
|
|
12
|
+
- Report unread items by count on a resume pass. Do not decide one.
|
|
13
|
+
|
|
14
|
+
## Authority
|
|
15
|
+
|
|
16
|
+
- Follow `.claude/standards/intake.md` for the folder layout, frontmatter and dating, the item format, and retrieval. It is the single source.
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
extends = ""
|
|
2
|
-
|
|
2
|
+
# Both folders whole. Every rule authored under them ships to a base consumer,
|
|
3
|
+
# so adding one is a single edit rather than a file and a name here.
|
|
4
|
+
rules = ["core", "claude"]
|
package/package.json
CHANGED
|
@@ -45,14 +45,34 @@ for payload in "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$SNIPPETS_JSON" "$T
|
|
|
45
45
|
fi
|
|
46
46
|
done
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
# The catalogs reach the eval as files rather than as environment entries. Linux
|
|
49
|
+
# caps a single env string at 128KB, and the standards payload crossed it, which
|
|
50
|
+
# fails the exec with E2BIG before any stage can report a stale count. A file
|
|
51
|
+
# path is bounded whatever the catalogs grow to.
|
|
52
|
+
PAYLOAD_DIR="$(mktemp -d)"
|
|
53
|
+
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
|
54
|
+
|
|
55
|
+
printf '%s' "$SKILLS_JSON" >"$PAYLOAD_DIR/skills.json"
|
|
56
|
+
printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
|
|
57
|
+
printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
|
|
58
|
+
printf '%s' "$SNIPPETS_JSON" >"$PAYLOAD_DIR/snippets.json"
|
|
59
|
+
printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
|
|
60
|
+
|
|
61
|
+
export PAYLOAD_DIR
|
|
49
62
|
export TEMPLATE OUTPUT LISTED PROJECT_ROOT
|
|
50
63
|
|
|
51
64
|
bun --eval '
|
|
52
|
-
const {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
const { readFileSync } = require("node:fs")
|
|
66
|
+
|
|
67
|
+
const { PAYLOAD_DIR, TEMPLATE, OUTPUT, LISTED, PROJECT_ROOT } = process.env
|
|
68
|
+
|
|
69
|
+
const payload = (name) => readFileSync(PAYLOAD_DIR + "/" + name + ".json", "utf8")
|
|
70
|
+
|
|
71
|
+
const SKILLS_JSON = payload("skills")
|
|
72
|
+
const GOV_JSON = payload("gov")
|
|
73
|
+
const STANDARDS_JSON = payload("standards")
|
|
74
|
+
const SNIPPETS_JSON = payload("snippets")
|
|
75
|
+
const TOOLING_JSON = payload("tooling")
|
|
56
76
|
|
|
57
77
|
const listed = Number(LISTED)
|
|
58
78
|
const skills = JSON.parse(SKILLS_JSON).skills.map((entry) => entry.name)
|
package/scripts/core/verify.sh
CHANGED
|
@@ -18,6 +18,12 @@ CHANGED_FILES=""
|
|
|
18
18
|
# unarmed and why.
|
|
19
19
|
SANDBOX_UNDECLARED_CEILING=47
|
|
20
20
|
|
|
21
|
+
# Rules no stack reaches, space separated and sorted the way `aitk gov list`
|
|
22
|
+
# emits them. Both are opt-in libraries a project may not want, so they are
|
|
23
|
+
# recorded here rather than in a config file: the list is what a reader compares
|
|
24
|
+
# a new arrival against, and a config file would absorb the arrival silently.
|
|
25
|
+
GOV_EXPECTED_UNREFERENCED="260-shadcn 320-tanstack-query"
|
|
26
|
+
|
|
21
27
|
check_dependencies() {
|
|
22
28
|
command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
|
|
23
29
|
}
|
|
@@ -208,6 +214,55 @@ main() {
|
|
|
208
214
|
run_check "bash $PROJECT_ROOT/scripts/core/check-plugin-boundary.sh" "Plugin ships toolkit-internal content."
|
|
209
215
|
log_info "Plugin boundary clean"
|
|
210
216
|
|
|
217
|
+
# A stack entry naming a rule folder takes every rule in it, which is what
|
|
218
|
+
# stops a new rule from needing a second edit to reach a target. The failure
|
|
219
|
+
# it leaves open is a rule authored into a folder no stack names, which
|
|
220
|
+
# installs for nobody and reports nothing on its own.
|
|
221
|
+
#
|
|
222
|
+
# This reports and never fails. Both standing findings are opt-in libraries
|
|
223
|
+
# this repository ships on purpose, so gating would fail every push over the
|
|
224
|
+
# deliberate case and teach a reader to route around the stage. Revisit when a
|
|
225
|
+
# third appears and the pattern is either a library set or an accident.
|
|
226
|
+
log_step "Unreferenced rules"
|
|
227
|
+
local gov_json gov_status=0 unreferenced
|
|
228
|
+
gov_json=$(cd "$PROJECT_ROOT" && bun src/cli.ts gov list --json 2>/dev/null) || gov_status=$?
|
|
229
|
+
if [ "$gov_status" -ne 0 ] || [ -z "$gov_json" ]; then
|
|
230
|
+
log_warn "Skipped, the governance catalog did not report"
|
|
231
|
+
else
|
|
232
|
+
# `bun --eval` rather than a grep, because the key holds an array of names
|
|
233
|
+
# and the numeric matches the stages above use reach a scalar alone.
|
|
234
|
+
#
|
|
235
|
+
# The `ok:` sentinel carries success rather than the exit code, because
|
|
236
|
+
# `bun --eval` reading piped stdin exits 0 even when the script throws.
|
|
237
|
+
# Measured on Bun 1.3.14: the same throw exits 1 with no pipe attached. A
|
|
238
|
+
# payload that parses as text but not as JSON would therefore print nothing
|
|
239
|
+
# and exit clean, and empty already means every rule is reached, so reading
|
|
240
|
+
# the exit code would report a broken catalog as a clean sweep. A missing
|
|
241
|
+
# or non-array key takes the same branch, since a stage that cannot measure
|
|
242
|
+
# should say so rather than claim the sweep found nothing.
|
|
243
|
+
local reported
|
|
244
|
+
reported=$(printf '%s' "$gov_json" | bun --eval '
|
|
245
|
+
try {
|
|
246
|
+
const data = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
|
|
247
|
+
if (!Array.isArray(data.unreferenced)) throw new Error("no field")
|
|
248
|
+
console.log("ok:" + data.unreferenced.join(" "))
|
|
249
|
+
} catch {
|
|
250
|
+
console.log("unreadable:")
|
|
251
|
+
}
|
|
252
|
+
')
|
|
253
|
+
unreferenced="${reported#ok:}"
|
|
254
|
+
if [ "${reported%%:*}" != "ok" ]; then
|
|
255
|
+
log_warn "Skipped, the governance catalog carried no readable unreferenced list"
|
|
256
|
+
elif [ -z "$unreferenced" ]; then
|
|
257
|
+
log_info "Every rule is reached by a stack"
|
|
258
|
+
elif [ "$unreferenced" = "$GOV_EXPECTED_UNREFERENCED" ]; then
|
|
259
|
+
log_info "Reached by no stack: $unreferenced (opt-in, install with --add)"
|
|
260
|
+
else
|
|
261
|
+
log_warn "Reached by no stack: $unreferenced"
|
|
262
|
+
log_warn "Expected: $GOV_EXPECTED_UNREFERENCED. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in this script and say why it ships opt-in."
|
|
263
|
+
fi
|
|
264
|
+
fi
|
|
265
|
+
|
|
211
266
|
# Only the citation half of the audit gates. Length, depth, table, and index
|
|
212
267
|
# findings are judgment thresholds, and failing a push on one would make the
|
|
213
268
|
# stage something to route around. `bun src/cli.ts` rather than `aitk`, since a
|
package/src/commands/gov.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
4
|
import type { Command } from 'commander'
|
|
5
|
-
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
6
5
|
import { PROJECT_ROOT } from '@/exec'
|
|
7
6
|
import { createGovAdapter } from '@/gov/adapter'
|
|
8
7
|
import { regenConsumedRules } from '@/gov/consumed'
|
|
9
8
|
import { hasStandards, installRules, lookupRules } from '@/gov/install'
|
|
9
|
+
import { buildGovCatalog, describeRule, describeStack } from '@/gov/list'
|
|
10
10
|
import { buildRulesPayload, listRuleFiles } from '@/gov/payload'
|
|
11
11
|
import {
|
|
12
12
|
govStackExists,
|
|
@@ -34,8 +34,6 @@ const NC = '\x1b[0m'
|
|
|
34
34
|
const PAYLOAD_REL = join('.claude', '.tmp', 'gov', 'rules.md')
|
|
35
35
|
const RULES_REL = join('.claude', 'rules')
|
|
36
36
|
|
|
37
|
-
const PASS_THROUGH_VERBS = ['list'] as const
|
|
38
|
-
|
|
39
37
|
interface InstallOptions {
|
|
40
38
|
readonly add?: string
|
|
41
39
|
}
|
|
@@ -44,6 +42,12 @@ interface RegenOptions {
|
|
|
44
42
|
readonly root?: string
|
|
45
43
|
}
|
|
46
44
|
|
|
45
|
+
interface ListOptions {
|
|
46
|
+
readonly stacks?: boolean
|
|
47
|
+
readonly rules?: boolean
|
|
48
|
+
readonly json?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
47
51
|
export function register(program: Command): void {
|
|
48
52
|
const gov = program
|
|
49
53
|
.command('gov')
|
|
@@ -119,7 +123,78 @@ export function register(program: Command): void {
|
|
|
119
123
|
process.exitCode = await runRegen(opts)
|
|
120
124
|
})
|
|
121
125
|
|
|
122
|
-
|
|
126
|
+
gov
|
|
127
|
+
.command('list')
|
|
128
|
+
.description('Emit the catalog of stacks and rules')
|
|
129
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
130
|
+
.option('--stacks', 'Only list stacks')
|
|
131
|
+
.option('--rules', 'Only list rules')
|
|
132
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
133
|
+
.action((opts: ListOptions) => {
|
|
134
|
+
process.exitCode = runList(opts)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Both selectors absent means both sections, which is the bash default. Naming
|
|
140
|
+
* both is the same as naming neither rather than an error, since the two flags
|
|
141
|
+
* read as filters and a caller passing both is asking for everything.
|
|
142
|
+
*/
|
|
143
|
+
function selectedSections(opts: ListOptions): {
|
|
144
|
+
stacks: boolean
|
|
145
|
+
rules: boolean
|
|
146
|
+
} {
|
|
147
|
+
const stacks = opts.stacks === true
|
|
148
|
+
const rules = opts.rules === true
|
|
149
|
+
if (stacks === rules) return { stacks: true, rules: true }
|
|
150
|
+
return { stacks, rules }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `JSON.stringify` replaces a `printf` that interpolated a description into a
|
|
155
|
+
* JSON string literal through a hand-rolled escaper, so a rule carrying a
|
|
156
|
+
* character that escaper missed emitted output a consuming skill could not
|
|
157
|
+
* parse.
|
|
158
|
+
*
|
|
159
|
+
* `unreferenced` rides the same payload rather than taking a flag of its own.
|
|
160
|
+
* The verify stage and a skill asking what a stack leaves out read one call,
|
|
161
|
+
* and the key is additive, so a consumer reading `stacks` or `rules` is
|
|
162
|
+
* untouched by it.
|
|
163
|
+
*/
|
|
164
|
+
function runList(opts: ListOptions): number {
|
|
165
|
+
const catalog = buildGovCatalog(PROJECT_ROOT)
|
|
166
|
+
const sections = selectedSections(opts)
|
|
167
|
+
|
|
168
|
+
if (opts.json) {
|
|
169
|
+
process.stdout.write(
|
|
170
|
+
`${JSON.stringify({
|
|
171
|
+
...(sections.stacks ? { stacks: catalog.stacks } : {}),
|
|
172
|
+
...(sections.rules ? { rules: catalog.rules } : {}),
|
|
173
|
+
unreferenced: catalog.unreferenced,
|
|
174
|
+
})}\n`,
|
|
175
|
+
)
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
intro('aitk gov list')
|
|
180
|
+
|
|
181
|
+
if (sections.stacks) {
|
|
182
|
+
logStep('Stacks')
|
|
183
|
+
for (const entry of catalog.stacks) logInfo(describeStack(entry))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (sections.rules) {
|
|
187
|
+
logStep('Rules')
|
|
188
|
+
for (const entry of catalog.rules) logInfo(describeRule(entry))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (catalog.unreferenced.length > 0) {
|
|
192
|
+
logStep('Reached by no stack')
|
|
193
|
+
for (const rule of catalog.unreferenced) logInfo(rule)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
outro()
|
|
197
|
+
return 0
|
|
123
198
|
}
|
|
124
199
|
|
|
125
200
|
/**
|
package/src/gov/install.ts
CHANGED
|
@@ -31,6 +31,37 @@ export function ruleSubdir(src: string, rulesRoot: string): string {
|
|
|
31
31
|
return subdir === '.' ? '' : subdir
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Lists every rule source path under `governance/rules/`, relative to it and
|
|
36
|
+
* sorted, so a caller walking the tree and a caller resolving one name read the
|
|
37
|
+
* same order.
|
|
38
|
+
*/
|
|
39
|
+
export function listRuleSourcePaths(root: string): string[] {
|
|
40
|
+
const rulesRoot = rulesSourceDir(root)
|
|
41
|
+
if (!existsSync(rulesRoot)) return []
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
|
|
45
|
+
].sort()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Maps each rule name to its source file. First path wins, so a name appearing
|
|
50
|
+
* in two subdirectories resolves deterministically rather than by whichever
|
|
51
|
+
* entry the filesystem yielded first.
|
|
52
|
+
*/
|
|
53
|
+
function indexRuleSources(root: string): Map<string, string> {
|
|
54
|
+
const rulesRoot = rulesSourceDir(root)
|
|
55
|
+
const byName = new Map<string, string>()
|
|
56
|
+
|
|
57
|
+
for (const rel of listRuleSourcePaths(root)) {
|
|
58
|
+
const name = rel.slice(rel.lastIndexOf('/') + 1, -'.md'.length)
|
|
59
|
+
if (!byName.has(name)) byName.set(name, join(rulesRoot, rel))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return byName
|
|
63
|
+
}
|
|
64
|
+
|
|
34
65
|
/**
|
|
35
66
|
* Finds each rule's source file by name across the `governance/rules/`
|
|
36
67
|
* subfolders. A rule with no source is reported rather than dropped, matching
|
|
@@ -41,21 +72,7 @@ export function lookupRules(
|
|
|
41
72
|
rules: readonly string[],
|
|
42
73
|
): RuleLookup {
|
|
43
74
|
const rulesRoot = rulesSourceDir(root)
|
|
44
|
-
const byName =
|
|
45
|
-
|
|
46
|
-
const relPaths = existsSync(rulesRoot)
|
|
47
|
-
? [
|
|
48
|
-
...new Bun.Glob('**/*.md').scanSync({
|
|
49
|
-
cwd: rulesRoot,
|
|
50
|
-
onlyFiles: true,
|
|
51
|
-
}),
|
|
52
|
-
].sort()
|
|
53
|
-
: []
|
|
54
|
-
|
|
55
|
-
for (const rel of relPaths) {
|
|
56
|
-
const name = rel.slice(rel.lastIndexOf('/') + 1, -'.md'.length)
|
|
57
|
-
if (!byName.has(name)) byName.set(name, join(rulesRoot, rel))
|
|
58
|
-
}
|
|
75
|
+
const byName = indexRuleSources(root)
|
|
59
76
|
|
|
60
77
|
const found: RuleSource[] = []
|
|
61
78
|
const missing: string[] = []
|
package/src/gov/list.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
|
|
4
|
+
import {
|
|
5
|
+
expandStackEntry,
|
|
6
|
+
listGovStacks,
|
|
7
|
+
loadGovStack,
|
|
8
|
+
unreferencedRules,
|
|
9
|
+
} from '@/gov/stacks'
|
|
10
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
11
|
+
|
|
12
|
+
export interface StackEntry {
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly extends: string | null
|
|
15
|
+
readonly rules: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RuleEntry {
|
|
19
|
+
readonly name: string
|
|
20
|
+
readonly domain: string
|
|
21
|
+
readonly description: string
|
|
22
|
+
readonly paths: string[] | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GovCatalog {
|
|
26
|
+
readonly stacks: StackEntry[]
|
|
27
|
+
readonly rules: RuleEntry[]
|
|
28
|
+
readonly unreferenced: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reports each stack's own entries expanded, so a folder entry reaches a
|
|
33
|
+
* consumer as the rules it stands for. `setup-gov` dedupes `--add` extras
|
|
34
|
+
* against this list, and a stack answering `core` there would re-add every
|
|
35
|
+
* rule that folder already carries.
|
|
36
|
+
*
|
|
37
|
+
* The `extends` chain is deliberately not resolved, which is what the bash
|
|
38
|
+
* did. The list shows what each stack contributes beside the parent it
|
|
39
|
+
* inherits from, rather than repeating the ancestors under every descendant.
|
|
40
|
+
*/
|
|
41
|
+
export function buildStackEntries(root: string): StackEntry[] {
|
|
42
|
+
const entries: StackEntry[] = []
|
|
43
|
+
|
|
44
|
+
for (const name of listGovStacks(root)) {
|
|
45
|
+
const stack = loadGovStack(root, name)
|
|
46
|
+
if (!stack) continue
|
|
47
|
+
|
|
48
|
+
const seen = new Set<string>()
|
|
49
|
+
const rules: string[] = []
|
|
50
|
+
|
|
51
|
+
for (const entry of stack.rules) {
|
|
52
|
+
for (const rule of expandStackEntry(root, entry)) {
|
|
53
|
+
if (seen.has(rule)) continue
|
|
54
|
+
seen.add(rule)
|
|
55
|
+
rules.push(rule)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
entries.push({ name, extends: stack.parent ?? null, rules })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return entries
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads the catalog straight off the source tree. A rule's domain is the
|
|
67
|
+
* subdirectory it sits in, which is the band grouping install preserves.
|
|
68
|
+
*/
|
|
69
|
+
export function buildRuleEntries(root: string): RuleEntry[] {
|
|
70
|
+
const rulesRoot = rulesSourceDir(root)
|
|
71
|
+
|
|
72
|
+
return listRuleSourcePaths(root).map((rel) => {
|
|
73
|
+
const frontmatter = parseFrontmatter(
|
|
74
|
+
readFileSync(join(rulesRoot, rel), 'utf8'),
|
|
75
|
+
)
|
|
76
|
+
const paths = frontmatter?.fields.paths
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: basename(rel, '.md'),
|
|
80
|
+
domain: rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : '',
|
|
81
|
+
description: readField(frontmatter, 'description') ?? '',
|
|
82
|
+
paths: Array.isArray(paths)
|
|
83
|
+
? paths.filter((entry): entry is string => typeof entry === 'string')
|
|
84
|
+
: null,
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildGovCatalog(root: string): GovCatalog {
|
|
90
|
+
return {
|
|
91
|
+
stacks: buildStackEntries(root),
|
|
92
|
+
rules: buildRuleEntries(root),
|
|
93
|
+
unreferenced: unreferencedRules(root),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function describeStack(entry: StackEntry): string {
|
|
98
|
+
const count = `${entry.rules.length} rules`
|
|
99
|
+
return entry.extends === null
|
|
100
|
+
? `${entry.name} (${count})`
|
|
101
|
+
: `${entry.name} (extends: ${entry.extends}, ${count})`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function describeRule(entry: RuleEntry): string {
|
|
105
|
+
return `${entry.name} [${entry.domain}] ${entry.description}`
|
|
106
|
+
}
|