@erclx/aitk 0.41.0 → 0.41.1
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-autoship/REQUIREMENT.md +5 -2
- package/claude/skills/claude-autoship/SKILL.md +16 -3
- package/docs/agents/install-and-sync.md +31 -1
- package/docs/agents/scripting.md +4 -0
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/src/commands/init.ts +4 -0
- package/src/commands/standards.ts +64 -7
- package/src/init/flags.ts +10 -3
- package/src/init/plan.ts +8 -1
- package/src/init/steps.ts +17 -1
- package/src/standards/closure.ts +200 -0
|
@@ -7,13 +7,15 @@ description: What the post-plan pipeline is for, the gaps it closes, and why eve
|
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, the run from an approved plan to an open pull request is a conversation. A session implements, then asks what comes next, and the answer varies by session.
|
|
10
|
+
Without this skill, the run from an approved plan to an open pull request is a conversation. A session implements, then asks what comes next, and the answer varies by session. A run that halts leaves no stated resume point, so the user reconstructs how far it got from the working tree.
|
|
11
|
+
|
|
12
|
+
Review is the step that varies most. It gets skipped on a diff that needed one, or spent on prose already gated by a hook. A file-extension test cannot separate the two, since a skill body and a documentation page are both markdown, so a branch changing what an agent does takes the skip a documentation branch earned.
|
|
11
13
|
|
|
12
14
|
## Must
|
|
13
15
|
|
|
14
16
|
- Take the approved plan for the branch as the scope, and implement only what it describes
|
|
15
17
|
- Give every step a stop condition, and leave the code on the branch and the receipts on disk at each one
|
|
16
|
-
- Classify the changed-file list
|
|
18
|
+
- Classify the changed-file list by path as well as by extension, so informational prose skips a code review with no signal on it and executable prose still reaches one
|
|
17
19
|
- Stop on any critical or should-fix finding rather than acting on it
|
|
18
20
|
- Open the pull request as a draft, then watch continuous integration to a terminal state
|
|
19
21
|
- Name the recovery for the stop it took, since the value of stopping is that the user knows where to resume
|
|
@@ -25,6 +27,7 @@ Without this skill, the run from an approved plan to an open pull request is a c
|
|
|
25
27
|
- Fix a review finding or a failing check. Both stops are deliberate, since a green pull request reached by auto-fix hides what broke.
|
|
26
28
|
- Run the memory Apply phase. Promoting an entry changes how the agent operates and ships as its own change.
|
|
27
29
|
- Read an empty changed-file list as prose-only. It satisfies that test vacuously and would route the branch past review instead of through it.
|
|
30
|
+
- Read a markdown extension as evidence the change only informs. A skill body, a governance rule, and a standard are behavior written in prose.
|
|
28
31
|
|
|
29
32
|
## Guards
|
|
30
33
|
|
|
@@ -71,13 +71,26 @@ An empty list stops the chain: `❌ No changed files to classify. Re-run when th
|
|
|
71
71
|
|
|
72
72
|
The two causes want different responses. A plan that has yet to produce its output is a re-run once it has. A plan whose output is gitignored by design, such as a read pass writing to `.claude/.tmp/`, is work the chain cannot carry at all, since `git-stage` finds nothing to commit six steps later. Never advise removing the output from `.gitignore`, which trades a stopped run for scratch committed into the repository.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
The skip needs both tests to pass: every changed file matches `*.md` or `*.txt`, and no changed file sits under a behavior path. On a pass, skip review entirely and continue to Step 7. Otherwise invoke `aitk:claude-review`.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
Behavior paths carry two spellings, the one a surface authors at and the one it installs to, so the rule reads the same in a toolkit and in a project that consumed one:
|
|
77
|
+
|
|
78
|
+
- `claude/skills/` and `.claude/skills/`
|
|
79
|
+
- `governance/rules/` and `.claude/rules/`
|
|
80
|
+
- `standards/` and `.claude/standards/`
|
|
81
|
+
- `snippets/` and `.claude/snippets/`
|
|
82
|
+
- `internal/` and `tooling/`, which hold the stack references and the seed documents a target is handed
|
|
83
|
+
- `CLAUDE.md` at the repository root, named as a file because a path prefix reaches nothing that sits in no folder
|
|
84
|
+
|
|
85
|
+
Markdown under one of them states what an agent does, so a change there is a behavior change wearing a prose extension. Everything outside them is informational, which keeps `docs/`, `README.md`, and `CHANGELOG.md` skipping without naming them. One behavior file sends the whole branch to review, since documentation shipped beside a behavior change does not cancel it.
|
|
86
|
+
|
|
87
|
+
Informational prose is already gated by `docs-sync`, `claude-standards-audit`, and pre-push hooks. Running a code-style review on it burns tokens with no signal.
|
|
88
|
+
|
|
89
|
+
The list covers this toolkit's authoring layout and the layout it installs, which is not every layout. A project keeping executable prose where neither spelling reaches adds the path, and until it does every branch touching it skips review silently.
|
|
77
90
|
|
|
78
91
|
## Step 6: evaluate findings
|
|
79
92
|
|
|
80
|
-
Skip this step
|
|
93
|
+
Skip this step when Step 5 skipped review. Otherwise read `.claude/review/review-<slug>.md` at the main worktree root. Parse the summary line (`X critical, Y should-fix, Z minor`):
|
|
81
94
|
|
|
82
95
|
- Any critical or should-fix count greater than zero, stop: `❌ Review found non-minor issues. See .claude/review/review-<slug>.md. Fix and run /git-ship.`
|
|
83
96
|
- Zero critical and zero should-fix, continue. The minor findings stay in the on-disk review receipt. Fold any a reviewer needs into the PR's `## Technical Context`. Do not add a separate review-notes section to the PR body.
|
|
@@ -42,6 +42,34 @@ headlessly, so a call that names its stack or category is unchanged.
|
|
|
42
42
|
`aitk snippets install`. Both resolve the target before anything else, so a path
|
|
43
43
|
that does not exist fails rather than being scaffolded.
|
|
44
44
|
|
|
45
|
+
## Standards selection
|
|
46
|
+
|
|
47
|
+
`aitk standards install --only <names>` takes a comma-separated list and
|
|
48
|
+
defaults to `all`, so a call that omits it installs the whole corpus as before.
|
|
49
|
+
A name resolves with or without its `.md` extension, and one that matches no
|
|
50
|
+
standard fails the run with exit 1 rather than being dropped, because a typo
|
|
51
|
+
would otherwise omit a standard silently and compute the closure over the wrong
|
|
52
|
+
set. `aitk init --standards <selection>` passes the same value through.
|
|
53
|
+
|
|
54
|
+
The selection expands to the standards it depends on, so nothing lands with a
|
|
55
|
+
dangling reference. A citation is a backticked filename in a standard's body,
|
|
56
|
+
resolved case-exactly against the flat `standards/` root, which is what drops a
|
|
57
|
+
citation naming a target's own `.claude/ARCHITECTURE.md` or a bundled standard
|
|
58
|
+
install never copies. Whatever the expansion adds is listed under its own step
|
|
59
|
+
in the output.
|
|
60
|
+
|
|
61
|
+
A citation inside a standard's `Does not govern:` list is a handoff rather than
|
|
62
|
+
a dependency, and the closure stops at it. That entry names a concern a sibling
|
|
63
|
+
owns and this standard does not, so a caller who did not ask for that concern
|
|
64
|
+
does not need the file. Each one is reported under a `Scope handoffs not
|
|
65
|
+
installed` step, naming what to add to `--only` if the project wants it after
|
|
66
|
+
all.
|
|
67
|
+
|
|
68
|
+
That split is what keeps a selection to a slice. Nearly all the citation density
|
|
69
|
+
in the corpus sits inside those scope lists, so following them pulls the whole
|
|
70
|
+
corpus in behind any single name. Following dependencies alone, a single name
|
|
71
|
+
lands between one and three of the fifteen.
|
|
72
|
+
|
|
45
73
|
## Governance regen
|
|
46
74
|
|
|
47
75
|
`aitk gov regen` is the one governance verb that runs against the toolkit root,
|
|
@@ -97,7 +125,9 @@ exits 1 naming the failures. Passing any flag skips the confirmation prompt,
|
|
|
97
125
|
which is what makes it scriptable. `--stack` defaults to `base`, and the default
|
|
98
126
|
does not read as a passed flag, so a bare `aitk init` installs governance and
|
|
99
127
|
still prompts. `--skip` takes `wiki`, `standards`, and `governance`, and warns
|
|
100
|
-
without aborting on any other value.
|
|
128
|
+
without aborting on any other value. `--standards` defaults to `all` and reaches
|
|
129
|
+
`aitk standards install` only when it names something narrower, so the default
|
|
130
|
+
run spawns the command it always did.
|
|
101
131
|
|
|
102
132
|
## Unguarded tooling primitives
|
|
103
133
|
|
package/docs/agents/scripting.md
CHANGED
|
@@ -101,8 +101,12 @@ AITK_NON_INTERACTIVE=1 aitk standards sync /path/to/project
|
|
|
101
101
|
# Copy every standard into a target, overwriting what is there
|
|
102
102
|
AITK_NON_INTERACTIVE=1 aitk standards install /path/to/project
|
|
103
103
|
|
|
104
|
+
# Install a named subset, expanded to the standards it cites
|
|
105
|
+
AITK_NON_INTERACTIVE=1 aitk standards install --only slug /path/to/project
|
|
106
|
+
|
|
104
107
|
# Bootstrap a project. Any flag suppresses the confirmation prompt
|
|
105
108
|
AITK_NON_INTERACTIVE=1 aitk init --stack astro --skip wiki /path/to/project
|
|
109
|
+
AITK_NON_INTERACTIVE=1 aitk init --standards design,wireframes /path/to/project
|
|
106
110
|
|
|
107
111
|
# Run every domain sync. The git workflow is refused headlessly, so nothing is pushed
|
|
108
112
|
AITK_NON_INTERACTIVE=1 aitk sync /path/to/project
|
package/docs/ai-workflow.md
CHANGED
|
@@ -100,7 +100,7 @@ For features on a mature stack, chain the post-plan pipeline in one session. App
|
|
|
100
100
|
|
|
101
101
|
- Use when the plan is tight and the stack has real verify commands and test coverage
|
|
102
102
|
- Autoship stops on: verify failure after one fix attempt, UI manual checklist non-empty, any review finding above minor, no diff baseline resolving against `main`, an empty changed-file list, or hook failure
|
|
103
|
-
- Review is skipped
|
|
103
|
+
- Review is skipped when the diff is prose that only informs: every changed file matches `*.md` or `*.txt`, and none sits under a behavior path. Behavior paths cover skills, rules, standards, snippets, and `tooling/` in both the authoring and the installed spelling, plus root `CLAUDE.md`, so the list matches whether a repository authors those surfaces or consumed them from the toolkit. Markdown under one states what an agent does, so a branch touching it reaches review while `docs/` and `wiki/` still skip and stay gated by `docs-sync`, `claude-standards-audit`, and pre-push hooks.
|
|
104
104
|
- An empty changed-file list stops the chain rather than counting as prose-only. The filename test passes vacuously on an empty set, which routed a branch past review instead of through it.
|
|
105
105
|
- Every stop leaves recoverable state. Fix and resume with `/git-ship`
|
|
106
106
|
- Skip autoship for auth, migrations, security-sensitive changes, or work where the plan itself is uncertain
|
package/docs/target-projects.md
CHANGED
|
@@ -88,7 +88,7 @@ When a new need appears after scaffold, install the one domain without re-runnin
|
|
|
88
88
|
- Project-specific rule the toolkit does not ship: invoke `aitk:create-rule`. It scaffolds a rule into `.claude/rules/` with a non-colliding number, and `aitk gov sync` leaves it untouched.
|
|
89
89
|
- Index.md system for a markdown-heavy folder that emerged: invoke `aitk:setup-indexes`
|
|
90
90
|
- A snippet preset or category: `aitk snippets install <preset|category|all> <path>`. The argument is required, since the picker refuses headlessly rather than choosing for the caller
|
|
91
|
-
- A single standard: `aitk standards install <
|
|
91
|
+
- A single standard: `aitk standards install --only <names> <path>`. The selection expands to the standards it depends on, and stops at a `Does not govern:` handoff, which it reports instead. Omitting the flag installs all of them
|
|
92
92
|
|
|
93
93
|
Per-domain mechanics live in the corresponding `docs/<domain>.md`. The skill body in `claude/skills/<skill>/SKILL.md` covers detection and preview.
|
|
94
94
|
|
package/package.json
CHANGED
package/src/commands/init.ts
CHANGED
|
@@ -17,6 +17,8 @@ interface InitOptions {
|
|
|
17
17
|
readonly stack: string
|
|
18
18
|
readonly add?: string
|
|
19
19
|
readonly snippets: string
|
|
20
|
+
/** Always present: the option falls back to `all`. */
|
|
21
|
+
readonly standards: string
|
|
20
22
|
readonly skip?: string
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -39,6 +41,7 @@ export function register(program: Command): void {
|
|
|
39
41
|
' aitk init ../my-app',
|
|
40
42
|
' aitk init --stack astro --add 260-shadcn ../my-app',
|
|
41
43
|
' aitk init --skip governance ../my-app',
|
|
44
|
+
' aitk init --standards design,wireframes ../my-app',
|
|
42
45
|
'',
|
|
43
46
|
].join('\n'),
|
|
44
47
|
)
|
|
@@ -66,6 +69,7 @@ async function runInit(
|
|
|
66
69
|
stack: options.stack,
|
|
67
70
|
add: options.add,
|
|
68
71
|
snippets: options.snippets,
|
|
72
|
+
standards: options.standards,
|
|
69
73
|
skip,
|
|
70
74
|
}
|
|
71
75
|
|
|
@@ -3,6 +3,7 @@ import type { Command } from 'commander'
|
|
|
3
3
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
4
4
|
import { PROJECT_ROOT } from '@/exec'
|
|
5
5
|
import { createStandardsAdapter, standardsSourceDir } from '@/standards/adapter'
|
|
6
|
+
import { ALL_SELECTION, selectStandards } from '@/standards/closure'
|
|
6
7
|
import {
|
|
7
8
|
refreshIndex,
|
|
8
9
|
STANDARDS_REL,
|
|
@@ -11,12 +12,26 @@ import {
|
|
|
11
12
|
import { applyInstall, planInstall } from '@/standards/install'
|
|
12
13
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
13
14
|
import { resolveTarget } from '@/target'
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
intro,
|
|
17
|
+
logAdd,
|
|
18
|
+
logError,
|
|
19
|
+
logInfo,
|
|
20
|
+
logStep,
|
|
21
|
+
logWarn,
|
|
22
|
+
outro,
|
|
23
|
+
select,
|
|
24
|
+
} from '@/ui'
|
|
15
25
|
|
|
16
26
|
const GREEN = '\x1b[0;32m'
|
|
17
27
|
const GREY = '\x1b[0;90m'
|
|
18
28
|
const NC = '\x1b[0m'
|
|
19
29
|
|
|
30
|
+
interface InstallOptions {
|
|
31
|
+
/** Always present: the option falls back to `ALL_SELECTION`. */
|
|
32
|
+
readonly only: string
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
export function register(program: Command): void {
|
|
21
36
|
const standards = program
|
|
22
37
|
.command('standards')
|
|
@@ -38,17 +53,36 @@ export function register(program: Command): void {
|
|
|
38
53
|
|
|
39
54
|
standards
|
|
40
55
|
.command('install')
|
|
41
|
-
.description('Copy
|
|
56
|
+
.description('Copy standards into a project (overwrites)')
|
|
42
57
|
.argument('[target]', 'Target directory', '.')
|
|
58
|
+
.option(
|
|
59
|
+
'--only <names>',
|
|
60
|
+
"Comma-separated standard names, or 'all'",
|
|
61
|
+
ALL_SELECTION,
|
|
62
|
+
)
|
|
43
63
|
.helpOption('-h, --help', 'Show this help message')
|
|
44
|
-
.
|
|
45
|
-
|
|
64
|
+
.addHelpText(
|
|
65
|
+
'after',
|
|
66
|
+
[
|
|
67
|
+
'',
|
|
68
|
+
'A selection expands to the standards it cites, so nothing lands with',
|
|
69
|
+
'a dangling reference.',
|
|
70
|
+
'',
|
|
71
|
+
'Examples:',
|
|
72
|
+
' aitk standards install',
|
|
73
|
+
' aitk standards install --only slug ../my-app',
|
|
74
|
+
' aitk standards install --only design,wireframes ../my-app',
|
|
75
|
+
'',
|
|
76
|
+
].join('\n'),
|
|
77
|
+
)
|
|
78
|
+
.action(async (target: string, options: InstallOptions) => {
|
|
79
|
+
process.exitCode = await runInstall(target, options.only)
|
|
46
80
|
})
|
|
47
81
|
|
|
48
82
|
registerPassThroughVerbs(standards, 'standards', ['list'])
|
|
49
83
|
}
|
|
50
84
|
|
|
51
|
-
async function runInstall(target: string): Promise<number> {
|
|
85
|
+
async function runInstall(target: string, selection: string): Promise<number> {
|
|
52
86
|
intro('aitk standards')
|
|
53
87
|
|
|
54
88
|
const resolved = resolveTarget(target, PROJECT_ROOT)
|
|
@@ -58,8 +92,31 @@ async function runInstall(target: string): Promise<number> {
|
|
|
58
92
|
const destDir = standardsInstallDir(resolved)
|
|
59
93
|
|
|
60
94
|
logStep('Scanning standards')
|
|
61
|
-
const
|
|
62
|
-
|
|
95
|
+
const available = planInstall(sourceDir)
|
|
96
|
+
const result = selectStandards(available, selection)
|
|
97
|
+
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
logError(
|
|
100
|
+
`Standard not found: ${result.unknown.join(', ')}. Run 'aitk standards list' for the catalog.`,
|
|
101
|
+
)
|
|
102
|
+
outro()
|
|
103
|
+
return 1
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { files, requested, added, unresolved } = result.selection
|
|
107
|
+
for (const name of requested) logInfo(join(STANDARDS_REL, name))
|
|
108
|
+
|
|
109
|
+
if (added.length > 0) {
|
|
110
|
+
logStep(`Added by citation (${added.length})`)
|
|
111
|
+
for (const name of added) logInfo(join(STANDARDS_REL, name))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (unresolved.length > 0) {
|
|
115
|
+
logStep(`Scope handoffs not installed (${unresolved.length})`)
|
|
116
|
+
for (const name of unresolved) logWarn(name)
|
|
117
|
+
logInfo('Each names a concern these standards do not govern. Add a name')
|
|
118
|
+
logInfo('to --only if the project needs that standard as well.')
|
|
119
|
+
}
|
|
63
120
|
|
|
64
121
|
const shouldInstall = await select({
|
|
65
122
|
message: `Install ${files.length} standards to ${destDir}?`,
|
package/src/init/flags.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
2
|
import { DEFAULT_STACK, SKIPPABLE_DOMAINS } from '@/init/plan'
|
|
3
|
+
import { ALL_SELECTION } from '@/standards/closure'
|
|
3
4
|
|
|
4
5
|
interface InitOptionSpec {
|
|
5
6
|
/** The option value key commander stores the parsed value under. */
|
|
@@ -32,6 +33,12 @@ export const INIT_OPTIONS: readonly InitOptionSpec[] = [
|
|
|
32
33
|
description: "Snippets preset, category, or 'all'",
|
|
33
34
|
defaultValue: 'essentials',
|
|
34
35
|
},
|
|
36
|
+
{
|
|
37
|
+
key: 'standards',
|
|
38
|
+
flags: '--standards <selection>',
|
|
39
|
+
description: "Comma-separated standard names, or 'all'",
|
|
40
|
+
defaultValue: ALL_SELECTION,
|
|
41
|
+
},
|
|
35
42
|
{
|
|
36
43
|
key: 'skip',
|
|
37
44
|
flags: '--skip <list>',
|
|
@@ -55,9 +62,9 @@ export function applyInitOptions(command: Command): Command {
|
|
|
55
62
|
|
|
56
63
|
/**
|
|
57
64
|
* Whether the operator passed any flag, which is what makes the command
|
|
58
|
-
* scriptable by suppressing the confirmation prompt. `--
|
|
59
|
-
*
|
|
60
|
-
* from rather than from the value itself.
|
|
65
|
+
* scriptable by suppressing the confirmation prompt. `--stack`, `--snippets`,
|
|
66
|
+
* and `--standards` all carry defaults, so presence has to be read from where
|
|
67
|
+
* the value came from rather than from the value itself.
|
|
61
68
|
*/
|
|
62
69
|
export function flagsProvided(cmd: Command): boolean {
|
|
63
70
|
return INIT_OPTIONS.some(
|
package/src/init/plan.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ALL_SELECTION } from '@/standards/closure'
|
|
2
|
+
|
|
1
3
|
export const SKIPPABLE_DOMAINS = ['wiki', 'standards', 'governance'] as const
|
|
2
4
|
|
|
3
5
|
export type SkippableDomain = (typeof SKIPPABLE_DOMAINS)[number]
|
|
@@ -31,6 +33,7 @@ export interface InitFlags {
|
|
|
31
33
|
readonly stack?: string
|
|
32
34
|
readonly add?: string
|
|
33
35
|
readonly snippets: string
|
|
36
|
+
readonly standards: string
|
|
34
37
|
readonly skip: SkipPlan
|
|
35
38
|
}
|
|
36
39
|
|
|
@@ -106,7 +109,11 @@ export function planInit(flags: InitFlags): InitPlan {
|
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
if (!flags.skip.skipped.has('standards')) {
|
|
109
|
-
|
|
112
|
+
const detail =
|
|
113
|
+
flags.standards === ALL_SELECTION
|
|
114
|
+
? 'authoring conventions'
|
|
115
|
+
: `${flags.standards}, plus what they cite`
|
|
116
|
+
preview.push({ level: 'info', text: `standards (${detail})` })
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
preview.push({ level: 'info', text: `snippets (${flags.snippets})` })
|
package/src/init/steps.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type InitFlags, resolveStack } from '@/init/plan'
|
|
2
2
|
import type { DomainStep } from '@/init/run'
|
|
3
|
+
import { ALL_SELECTION } from '@/standards/closure'
|
|
3
4
|
|
|
4
5
|
/** Builds the child-process invocation for one domain. */
|
|
5
6
|
export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
|
|
@@ -52,7 +53,7 @@ export function buildSteps(
|
|
|
52
53
|
steps.push({
|
|
53
54
|
kind: 'run',
|
|
54
55
|
label: 'Standards',
|
|
55
|
-
run: child(
|
|
56
|
+
run: child(standardsArgs(flags.standards, resolved)),
|
|
56
57
|
})
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -73,6 +74,21 @@ export function buildSteps(
|
|
|
73
74
|
return steps
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Builds the `standards install` argv. `all` is left off rather than spelled
|
|
79
|
+
* out, so the default init runs the same command it ran before the flag
|
|
80
|
+
* existed.
|
|
81
|
+
*/
|
|
82
|
+
function standardsArgs(selection: string, path: string): string[] {
|
|
83
|
+
const args = ['standards', 'install']
|
|
84
|
+
if (selection !== '' && selection !== ALL_SELECTION) {
|
|
85
|
+
args.push('--only', selection)
|
|
86
|
+
}
|
|
87
|
+
args.push(path)
|
|
88
|
+
|
|
89
|
+
return args
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
/**
|
|
77
93
|
* Builds the `gov install` argv. The run and the recovery command a skip prints
|
|
78
94
|
* come from here both, so the command a caller is told to paste installs what
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
import type { StandardsSource } from '@/standards/install'
|
|
4
|
+
|
|
5
|
+
export const ALL_SELECTION = 'all'
|
|
6
|
+
|
|
7
|
+
export interface Citations {
|
|
8
|
+
/** Siblings the body depends on, which the closure follows. */
|
|
9
|
+
readonly cited: readonly string[]
|
|
10
|
+
/** Siblings a `Does not govern:` entry hands off to, which it does not. */
|
|
11
|
+
readonly delegated: readonly string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StandardsSelection {
|
|
15
|
+
readonly files: readonly StandardsSource[]
|
|
16
|
+
/** What the caller named, in the order the flat root lists it. */
|
|
17
|
+
readonly requested: readonly string[]
|
|
18
|
+
/** What a requested standard cites and the caller did not name. */
|
|
19
|
+
readonly added: readonly string[]
|
|
20
|
+
/** Handoff targets that did not land, so their pointers will not resolve. */
|
|
21
|
+
readonly unresolved: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SelectionResult =
|
|
25
|
+
| { readonly ok: true; readonly selection: StandardsSelection }
|
|
26
|
+
| { readonly ok: false; readonly unknown: readonly string[] }
|
|
27
|
+
|
|
28
|
+
const CITATION = /`([^`\n]+?\.md)`/g
|
|
29
|
+
const DELEGATION_START = /^Does not govern:/
|
|
30
|
+
const HEADING = /^#{1,6}\s/
|
|
31
|
+
|
|
32
|
+
/** Accepts `prose` and `prose.md` alike, since the catalog lists both spellings. */
|
|
33
|
+
export function normalizeName(raw: string): string {
|
|
34
|
+
const name = raw.trim()
|
|
35
|
+
return name.endsWith('.md') ? name : `${name}.md`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseSelection(csv: string): string[] {
|
|
39
|
+
return csv
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((raw) => raw.trim())
|
|
42
|
+
.filter((raw) => raw !== '')
|
|
43
|
+
.map(normalizeName)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Splits a body at the `Does not govern:` list, which runs to the next heading.
|
|
48
|
+
* Every standard in the flat root carries exactly one, directly under `## Scope`.
|
|
49
|
+
*/
|
|
50
|
+
function splitDelegatedScope(body: string): {
|
|
51
|
+
governing: string
|
|
52
|
+
delegated: string
|
|
53
|
+
} {
|
|
54
|
+
const governing: string[] = []
|
|
55
|
+
const delegated: string[] = []
|
|
56
|
+
let inDelegation = false
|
|
57
|
+
|
|
58
|
+
for (const line of body.split('\n')) {
|
|
59
|
+
if (DELEGATION_START.test(line)) inDelegation = true
|
|
60
|
+
else if (inDelegation && HEADING.test(line)) inDelegation = false
|
|
61
|
+
|
|
62
|
+
if (inDelegation) delegated.push(line)
|
|
63
|
+
else governing.push(line)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { governing: governing.join('\n'), delegated: delegated.join('\n') }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchNames(text: string, available: ReadonlySet<string>): string[] {
|
|
70
|
+
const found = new Set<string>()
|
|
71
|
+
|
|
72
|
+
for (const match of text.matchAll(CITATION)) {
|
|
73
|
+
const token = match[1]
|
|
74
|
+
if (token === undefined) continue
|
|
75
|
+
|
|
76
|
+
const name = basename(token)
|
|
77
|
+
if (available.has(name)) found.add(name)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [...found]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reads the sibling standards a body cites, split by whether the citation is a
|
|
85
|
+
* dependency or a handoff. A citation is a backticked token ending in `.md`,
|
|
86
|
+
* which is the only place either relationship is written, so the parse is a
|
|
87
|
+
* heuristic and every candidate is resolved against `available` before it
|
|
88
|
+
* counts. That resolution is what drops a fenced example, a target project's
|
|
89
|
+
* `.claude/ARCHITECTURE.md`, and a bundled standard the flat root does not
|
|
90
|
+
* install, none of which a selection should pull in.
|
|
91
|
+
*
|
|
92
|
+
* A citation inside the `Does not govern:` list is `delegated` rather than
|
|
93
|
+
* `cited`, because that entry says the sibling owns a concern this standard
|
|
94
|
+
* does not. Expanding on it pulls in a file the caller declined by not naming
|
|
95
|
+
* it, and nearly all the corpus density sits in those lists, which is what
|
|
96
|
+
* collapsed every selection into the whole corpus. A name appearing in the list
|
|
97
|
+
* and also outside it stays `cited`, since a real dependency outranks a handoff.
|
|
98
|
+
*
|
|
99
|
+
* Matching is case-exact against the listing rather than a filesystem probe,
|
|
100
|
+
* because a case-insensitive volume would otherwise resolve `SKILL.md` onto
|
|
101
|
+
* `skill.md` and expand a selection on a citation that names a target's own
|
|
102
|
+
* file. The basename is what resolves, so `standards/versioning.md` and a bare
|
|
103
|
+
* `versioning.md` read as the same dependency.
|
|
104
|
+
*/
|
|
105
|
+
export function citedStandards(
|
|
106
|
+
body: string,
|
|
107
|
+
available: ReadonlySet<string>,
|
|
108
|
+
): Citations {
|
|
109
|
+
const { governing, delegated } = splitDelegatedScope(body)
|
|
110
|
+
const cited = matchNames(governing, available)
|
|
111
|
+
const citedSet = new Set(cited)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
cited,
|
|
115
|
+
delegated: matchNames(delegated, available).filter(
|
|
116
|
+
(name) => !citedSet.has(name),
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Expands a selection to the transitive closure of what it cites, so an install
|
|
123
|
+
* cannot land a standard whose citations dangle. `all` and an empty selection
|
|
124
|
+
* both mean every standard, which is what keeps the existing callers unchanged.
|
|
125
|
+
*
|
|
126
|
+
* The closure follows dependencies alone. A `Does not govern:` handoff names a
|
|
127
|
+
* concern the caller declined by not selecting it, so the target stays out and
|
|
128
|
+
* is reported through `unresolved` instead. Following those too pulls the whole
|
|
129
|
+
* corpus in behind any single name.
|
|
130
|
+
*
|
|
131
|
+
* An unrecognized name fails the whole selection rather than being dropped with
|
|
132
|
+
* a warning, unlike `--skip` on `aitk init`. A typo here silently omits a
|
|
133
|
+
* standard the caller asked for, and the closure would then be computed over
|
|
134
|
+
* the wrong set.
|
|
135
|
+
*/
|
|
136
|
+
export function selectStandards(
|
|
137
|
+
available: readonly StandardsSource[],
|
|
138
|
+
selection: string,
|
|
139
|
+
): SelectionResult {
|
|
140
|
+
const byName = new Map(available.map((file) => [file.name, file]))
|
|
141
|
+
|
|
142
|
+
if (selection.trim() === '' || selection.trim() === ALL_SELECTION) {
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
selection: {
|
|
146
|
+
files: available,
|
|
147
|
+
requested: available.map((file) => file.name),
|
|
148
|
+
added: [],
|
|
149
|
+
unresolved: [],
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const requested = parseSelection(selection)
|
|
155
|
+
const unknown = requested.filter((name) => !byName.has(name))
|
|
156
|
+
if (unknown.length > 0) return { ok: false, unknown }
|
|
157
|
+
|
|
158
|
+
const names = new Set(byName.keys())
|
|
159
|
+
const resolved = new Set(requested)
|
|
160
|
+
const handoffs = new Set<string>()
|
|
161
|
+
const queue = [...requested]
|
|
162
|
+
|
|
163
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
164
|
+
const name = queue[index]
|
|
165
|
+
if (name === undefined) continue
|
|
166
|
+
|
|
167
|
+
const file = byName.get(name)
|
|
168
|
+
if (file === undefined) continue
|
|
169
|
+
|
|
170
|
+
const citations = citedStandards(readFileSync(file.path, 'utf8'), names)
|
|
171
|
+
for (const handoff of citations.delegated) handoffs.add(handoff)
|
|
172
|
+
|
|
173
|
+
for (const cited of citations.cited) {
|
|
174
|
+
if (resolved.has(cited)) continue
|
|
175
|
+
|
|
176
|
+
resolved.add(cited)
|
|
177
|
+
queue.push(cited)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const requestedSet = new Set(requested)
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
selection: {
|
|
186
|
+
files: available.filter((file) => resolved.has(file.name)),
|
|
187
|
+
requested: available
|
|
188
|
+
.filter((file) => requestedSet.has(file.name))
|
|
189
|
+
.map((file) => file.name),
|
|
190
|
+
added: available
|
|
191
|
+
.filter(
|
|
192
|
+
(file) => resolved.has(file.name) && !requestedSet.has(file.name),
|
|
193
|
+
)
|
|
194
|
+
.map((file) => file.name),
|
|
195
|
+
unresolved: available
|
|
196
|
+
.filter((file) => handoffs.has(file.name) && !resolved.has(file.name))
|
|
197
|
+
.map((file) => file.name),
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|