@erclx/aitk 0.10.0 → 0.11.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
package/docs/agents.md CHANGED
@@ -136,6 +136,16 @@ headlessly, so a call that names its stack or category is unchanged.
136
136
  `aitk snippets install`. Both resolve the target before anything else, so a path
137
137
  that does not exist fails rather than being scaffolded.
138
138
 
139
+ `aitk gov regen` is the one governance verb that runs against the toolkit root,
140
+ because the `.claude/rules/` it writes there is produced output rather than an
141
+ operator's working copy. It reads the stack recorded in `internal/governance.toml`,
142
+ installs it alongside anything under `internal/rules/`, and clears the
143
+ destination first so a rule the record stopped naming disappears. It takes
144
+ `--root <path>` and defaults to the toolkit root, prints nothing on success, and
145
+ reports the reason on stderr with exit 1 when the record names a stack or rule
146
+ that does not resolve. `scripts/core/regen-claude-copies.sh` calls it, and the
147
+ Consumed copies stage of `bun run check` asserts the result is committed.
148
+
139
149
  `aitk sync` runs every installed domain sync, then offers to commit the result
140
150
  and open a pull request. Under `AITK_NON_INTERACTIVE=1` it applies the domain
141
151
  syncs and then refuses the git workflow, reporting the branch and commit it
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -23,4 +23,13 @@ mirror_dir "$PROJECT_ROOT/snippets" "$PROJECT_ROOT/.claude/snippets" -name "*.md
23
23
 
24
24
  # `internal/` is the surface the plugin does not symlink. Mirrored on its own so
25
25
  # toolkit sessions read it at a `.claude/` path like every other consumed copy.
26
- mirror_dir "$PROJECT_ROOT/internal" "$PROJECT_ROOT/.claude/internal" -name "*.md"
26
+ # `internal/rules/` is excluded because it lands in `.claude/rules/` below, and
27
+ # mirroring it here too would publish each rule at a second inert path. The
28
+ # exclusion is anchored to that one folder, since an unanchored `*/rules/*` would
29
+ # also drop a later `internal/standards/rules/` and report nothing for it.
30
+ mirror_dir "$PROJECT_ROOT/internal" "$PROJECT_ROOT/.claude/internal" -name "*.md" -not -path "$PROJECT_ROOT/internal/rules/*"
31
+
32
+ # `.claude/rules/` is a subset rather than a mirror, so it resolves through the
33
+ # stack machinery instead of a fourth `mirror_dir` call. The record naming the
34
+ # subset is `internal/governance.toml`.
35
+ bun "$PROJECT_ROOT/src/cli.ts" gov regen --root "$PROJECT_ROOT"
@@ -132,7 +132,7 @@ main() {
132
132
 
133
133
  log_step "Consumed copies"
134
134
  run_check "bash $PROJECT_ROOT/scripts/core/regen-claude-copies.sh" "Consumed-copy regen failed"
135
- assert_no_drift ".claude/standards .claude/snippets .claude/internal" "Consumed copies drifted. Run bun run check and commit .claude/standards, .claude/snippets, and .claude/internal."
135
+ assert_no_drift ".claude/standards .claude/snippets .claude/internal .claude/rules" "Consumed copies drifted. Run bun run check and commit .claude/standards, .claude/snippets, .claude/internal, and .claude/rules."
136
136
  log_info "Consumed copies clean"
137
137
 
138
138
  log_step "Skill references"
@@ -5,6 +5,7 @@ import type { Command } from 'commander'
5
5
  import { registerPassThroughVerbs } from '@/commands/pass-through'
6
6
  import { PROJECT_ROOT } from '@/exec'
7
7
  import { createGovAdapter } from '@/gov/adapter'
8
+ import { regenConsumedRules } from '@/gov/consumed'
8
9
  import { hasStandards, installRules, lookupRules } from '@/gov/install'
9
10
  import { buildRulesPayload, listRuleFiles } from '@/gov/payload'
10
11
  import {
@@ -39,6 +40,10 @@ interface InstallOptions {
39
40
  readonly add?: string
40
41
  }
41
42
 
43
+ interface RegenOptions {
44
+ readonly root?: string
45
+ }
46
+
42
47
  export function register(program: Command): void {
43
48
  const gov = program
44
49
  .command('gov')
@@ -95,9 +100,44 @@ export function register(program: Command): void {
95
100
  process.exitCode = await runBuild(target)
96
101
  })
97
102
 
103
+ gov
104
+ .command('regen')
105
+ .description("Rebuild a repository's own .claude/rules/ from its record")
106
+ .helpOption('-h, --help', 'Show this help message')
107
+ .option('--root <path>', 'Repository root to regenerate', PROJECT_ROOT)
108
+ .addHelpText(
109
+ 'after',
110
+ [
111
+ '',
112
+ 'Reads internal/governance.toml and installs the stack it names, plus',
113
+ 'any rules under internal/rules/. Unlike install and sync, this runs',
114
+ 'against the toolkit root, whose .claude/rules/ is produced output.',
115
+ '',
116
+ ].join('\n'),
117
+ )
118
+ .action(async (opts: RegenOptions) => {
119
+ process.exitCode = await runRegen(opts)
120
+ })
121
+
98
122
  registerPassThroughVerbs(gov, 'gov', PASS_THROUGH_VERBS)
99
123
  }
100
124
 
125
+ /**
126
+ * Silent on success so the consumed-copy stage that calls it stays as quiet as
127
+ * the three `mirror_dir` lines it sits beside. The installed set is readable on
128
+ * disk, so printing it would only add noise to every `bun run check`.
129
+ */
130
+ async function runRegen(opts: RegenOptions): Promise<number> {
131
+ const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
132
+
133
+ if (!result.ok) {
134
+ process.stderr.write(`Consumed-rules regen failed: ${result.reason}\n`)
135
+ return 1
136
+ }
137
+
138
+ return 0
139
+ }
140
+
101
141
  /**
102
142
  * Renders the target-relative path the prompt quotes, keeping the argument the
103
143
  * caller typed rather than the absolute path it resolves to.
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { rm } from 'node:fs/promises'
3
+ import { basename, join } from 'node:path'
4
+ import {
5
+ installRules,
6
+ installedRulesDir,
7
+ lookupRules,
8
+ type RuleSource,
9
+ ruleSubdir,
10
+ } from '@/gov/install'
11
+ import { mergeExtraRules, resolveRules } from '@/gov/stacks'
12
+
13
+ export const RECORD_REL = join('internal', 'governance.toml')
14
+
15
+ const INTERNAL_RULES_REL = join('internal', 'rules')
16
+
17
+ /** The stack a repository installs into its own `.claude/rules/`. */
18
+ export interface ConsumedRecord {
19
+ readonly stack: string
20
+ readonly add: readonly string[]
21
+ }
22
+
23
+ export type ConsumedResult =
24
+ | { readonly ok: true; readonly installed: readonly string[] }
25
+ | { readonly ok: false; readonly reason: string }
26
+
27
+ export function consumedRecordPath(root: string): string {
28
+ return join(root, RECORD_REL)
29
+ }
30
+
31
+ export function internalRulesDir(root: string): string {
32
+ return join(root, INTERNAL_RULES_REL)
33
+ }
34
+
35
+ /**
36
+ * Reads the record naming the consumed stack. A file with no `stack` reads the
37
+ * same as no file at all, since neither tells the producer what to install and
38
+ * the caller's message covers both.
39
+ */
40
+ export function readConsumedRecord(root: string): ConsumedRecord | undefined {
41
+ const path = consumedRecordPath(root)
42
+ if (!existsSync(path)) return undefined
43
+
44
+ const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
45
+ string,
46
+ unknown
47
+ >
48
+ const stack = typeof parsed.stack === 'string' ? parsed.stack : ''
49
+ if (stack === '') return undefined
50
+
51
+ return {
52
+ stack,
53
+ add: Array.isArray(parsed.add)
54
+ ? parsed.add.filter(
55
+ (rule): rule is string => typeof rule === 'string' && rule !== '',
56
+ )
57
+ : [],
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Lists rules authored under `internal/rules/`. These govern toolkit authoring
63
+ * against paths only this repository has, so they install into the consumed
64
+ * copy without ever entering `governance/rules/`, which ships to targets.
65
+ */
66
+ export function listInternalRules(root: string): RuleSource[] {
67
+ const dir = internalRulesDir(root)
68
+ if (!existsSync(dir)) return []
69
+
70
+ return [...new Bun.Glob('**/*.md').scanSync({ cwd: dir, onlyFiles: true })]
71
+ .sort()
72
+ .map((rel) => {
73
+ const src = join(dir, rel)
74
+ return { rule: basename(rel, '.md'), src, subdir: ruleSubdir(src, dir) }
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Rebuilds a repository's own `.claude/rules/` from its record. Unlike
80
+ * `gov install` and `gov sync`, this runs against the toolkit root on purpose:
81
+ * those two refuse it because a target's rules are the operator's to edit,
82
+ * while this destination is produced output that happens to live beside its
83
+ * source.
84
+ */
85
+ export async function regenConsumedRules(
86
+ root: string,
87
+ ): Promise<ConsumedResult> {
88
+ const record = readConsumedRecord(root)
89
+ if (record === undefined) {
90
+ return { ok: false, reason: `No stack recorded at ${RECORD_REL}` }
91
+ }
92
+
93
+ const resolution = resolveRules(root, record.stack)
94
+ if (!resolution.ok) {
95
+ return { ok: false, reason: `Stack not found: ${resolution.missingStack}` }
96
+ }
97
+
98
+ const { found, missing } = lookupRules(
99
+ root,
100
+ mergeExtraRules(resolution.rules, record.add.join(',')),
101
+ )
102
+ if (missing.length > 0) {
103
+ return { ok: false, reason: `No source for: ${missing.join(', ')}` }
104
+ }
105
+
106
+ const internal = listInternalRules(root)
107
+ const stackRules = new Set(found.map((entry) => entry.rule))
108
+ const shadowed = internal
109
+ .filter((entry) => stackRules.has(entry.rule))
110
+ .map((entry) => entry.rule)
111
+ if (shadowed.length > 0) {
112
+ return {
113
+ ok: false,
114
+ reason: `Internal rules shadow stack rules: ${shadowed.join(', ')}`,
115
+ }
116
+ }
117
+
118
+ // Clearing first is what makes a rule the record stopped naming disappear.
119
+ // Copying over the destination would leave it behind as an unsourced file,
120
+ // which is the state this producer exists to end.
121
+ await rm(installedRulesDir(root), { recursive: true, force: true })
122
+
123
+ const installed = [
124
+ ...(await installRules(found, root)),
125
+ ...(await installRules(internal, root)),
126
+ ]
127
+
128
+ return { ok: true, installed: installed.sort() }
129
+ }