@erclx/aitk 3.8.0 → 3.9.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/create-skill/REQUIREMENT.md +5 -1
- package/claude/skills/create-skill/SKILL.md +10 -4
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/restated.md +78 -0
- package/governance/rules/claude/570-skill.md +2 -0
- package/package.json +1 -1
- package/src/audits/catalog.ts +49 -0
- package/src/cli.ts +1 -0
- package/src/commands/gov.ts +160 -0
- package/src/gov/restated.ts +688 -0
- package/standards/skill.md +1 -0
|
@@ -11,9 +11,12 @@ Without this skill, a new skill lands in the wrong shape and the wrong place. A
|
|
|
11
11
|
|
|
12
12
|
A skill born without its requirement is the second failure, and it surfaces much later. Coverage of the corpus is what the operator reads to decide whether a skill should exist, so every skill created without the sibling decays that reading, and the sweep that closes the gap has to reconstruct what the skill was for from the body it already shipped.
|
|
13
13
|
|
|
14
|
+
A third failure is a folder created for a moment a rule, a verb, or an existing skill already reaches, or for a procedure no session would get wrong from first principles. Nothing caught that before the folder existed, so the corpus grew by a skill an inward audit later had to argue for removing, with only a commit body recording the argument.
|
|
15
|
+
|
|
14
16
|
## Must
|
|
15
17
|
|
|
16
18
|
- Read the authoring standard and the prose standard before drafting, so the draft starts conformant rather than getting corrected into shape
|
|
19
|
+
- Answer, in writing, whether a rule, a verb, or an existing skill already reaches this moment, and whether the body carries a procedure a session would get wrong from first principles. A folder that fails either belongs to the surface that already covers it, not to a new skill.
|
|
17
20
|
- Draft the sibling `REQUIREMENT.md` beside the body, from what the skill is for rather than from the drafted body
|
|
18
21
|
- Confirm the name and both files with the user before writing. The name is the routing key and a folder that disagrees with its frontmatter fails silently.
|
|
19
22
|
- Write to the conventional skills path, so discovery finds it without configuration
|
|
@@ -23,6 +26,7 @@ A skill born without its requirement is the second failure, and it surfaces much
|
|
|
23
26
|
- Auto-trigger. Creation is a deliberate act and a skill invented from an ambiguous request is worse than none.
|
|
24
27
|
- Write either file before the user has seen it
|
|
25
28
|
- Derive the requirement from the drafted body, which records the draft's overfitting as the requirement
|
|
29
|
+
- Gate creation on whether anything will invoke the new skill beyond the author typing its name. That question has no answer yet.
|
|
26
30
|
|
|
27
31
|
## Guards
|
|
28
32
|
|
|
@@ -31,4 +35,4 @@ A skill born without its requirement is the second failure, and it surfaces much
|
|
|
31
35
|
## Out of scope
|
|
32
36
|
|
|
33
37
|
- Editing an existing skill, which the authoring standard and the skill's own requirement govern
|
|
34
|
-
-
|
|
38
|
+
- Whether anything ends up invoking the new skill: the usage census in `.claude/context/claude-plugin/skill-strategy.md`, read once the skill has run
|
|
@@ -18,9 +18,15 @@ Read these files in parallel:
|
|
|
18
18
|
|
|
19
19
|
## Steps
|
|
20
20
|
|
|
21
|
-
1.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
1. Answer three questions in writing before drafting anything. Tell the user and stop if a rule, a verb, or an existing skill already reaches this moment, or if the body carries no procedure a session would get wrong from first principles:
|
|
22
|
+
- Does a rule, a verb, or an existing skill already reach this moment? A folder that only wraps something already reachable duplicates it rather than adding to the catalog.
|
|
23
|
+
- Does the body carry a procedure a session would get wrong from first principles? A one-line wrapper around a single command needs no skill.
|
|
24
|
+
- Will anything invoke it other than the author typing its name? Carry this one into the drafted `REQUIREMENT.md`'s `Must not` section as a review criterion instead, since nothing can answer it before the skill has run.
|
|
25
|
+
2. Draft the full `SKILL.md` from the user's description
|
|
26
|
+
3. Draft the sibling `REQUIREMENT.md` from what the skill is for, in the shape the standard states. Write the gaps from the user's description rather than from the drafted body, since a requirement derived from the body records whatever the draft overfitted to.
|
|
27
|
+
4. Confirm the skill name and both files with the user before writing
|
|
28
|
+
5. Write to `.claude/skills/<name>/SKILL.md` and `.claude/skills/<name>/REQUIREMENT.md`
|
|
25
29
|
|
|
26
30
|
Every skill carries a requirement. A skill created without one is a gap someone closes in a later sweep, and the sweep has to reconstruct what the skill was for from the body it already shipped.
|
|
31
|
+
|
|
32
|
+
The two-question check has a shipped precedent. Pull request `#516` dropped `git-stash` for wrapping a single git command that needed no skill body, and `release-changelog` for being low-frequency and invoked by no other skill. The first is question one, and the second is question three read back after the fact, which is why it stays a review criterion rather than something this skill can gate on.
|
package/docs/agents/audits.md
CHANGED
|
@@ -22,11 +22,11 @@ aitk audits list --json
|
|
|
22
22
|
|
|
23
23
|
## What it runs
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Seventeen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
|
|
26
26
|
|
|
27
27
|
The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `aitk deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Fourteen of the seventeen read a tree on this disk, the one added by `restated.md` among them, since it reads three such trees against each other. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
|
|
30
30
|
|
|
31
31
|
Each is invoked as the CLI the caller is running rather than as a global `aitk`. A globally installed binary resolves to the main checkout no matter which worktree is executing, so the aggregate would measure a tree the branch never touched and report a pass over it.
|
|
32
32
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -50,6 +50,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
50
50
|
| `aitk claude routing` | Report per `CLAUDE.md` section how many bullets name a path and how many of those a path-scoped rule already covers (`--json`) |
|
|
51
51
|
| `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
|
|
52
52
|
| `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
|
|
53
|
+
| `aitk gov restated` | Report every instruction the always-loaded file shares with the seed or a shipped skill body, classed and with its anchors named (`--json`) |
|
|
53
54
|
| `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
|
|
54
55
|
| `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
|
|
55
56
|
| `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
|
package/docs/agents/index.md
CHANGED
|
@@ -24,6 +24,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
24
24
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
25
25
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
26
26
|
- [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
27
|
+
- [Restated instructions](restated.md): Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
|
|
27
28
|
- [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
|
|
28
29
|
- [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
|
|
29
30
|
- [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Restated instructions
|
|
3
|
+
description: Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Restated instructions
|
|
7
|
+
|
|
8
|
+
`aitk gov restated` reports every instruction the always-loaded file states that a second surface states too. It answers a question nothing else here could: one rule was found written in three places, and it was found by accident when a shipped skill deleted a file the rule said to keep.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
aitk gov restated
|
|
12
|
+
aitk gov restated --json
|
|
13
|
+
aitk gov restated --root ../my-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Option | Behavior |
|
|
17
|
+
| --------------- | ---------------------------------------------------------- |
|
|
18
|
+
| `--root <path>` | Tree to read, defaulting to the current directory |
|
|
19
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
20
|
+
|
|
21
|
+
Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
|
|
22
|
+
|
|
23
|
+
## The corpus it reads
|
|
24
|
+
|
|
25
|
+
Three surfaces, and they are not read symmetrically. Every top-level bullet in `CLAUDE.md` is a subject, and the other two are searched:
|
|
26
|
+
|
|
27
|
+
- `tooling/claude/seeds/CLAUDE.md`, read as bullets, since the seed carries the same shape as the file it is authored from
|
|
28
|
+
- `claude/skills/*/SKILL.md`, read as every prose line and bullet, since the motivating case was stated in a body as a paragraph rather than a list item
|
|
29
|
+
|
|
30
|
+
Frontmatter, headings, tables, and fenced blocks are read past. A heading names a section instead of stating a rule, a fenced block is an example whose words belong to the prose around it, and a body's `description` restates that skill's own purpose, so sweeping it would match every subject naming its domain.
|
|
31
|
+
|
|
32
|
+
A rule stated in two skill bodies and never in `CLAUDE.md` is outside this reading. The bullets there are the subjects, and a subject is what the search runs from.
|
|
33
|
+
|
|
34
|
+
## How a match is decided
|
|
35
|
+
|
|
36
|
+
Matching is recall-first and keyed on shared distinctive tokens rather than on a phrase two surfaces spell the same way. The case this exists for was one rule written three different ways, so a near-exact matcher would ship a cheap report blind to the defect it was built for.
|
|
37
|
+
|
|
38
|
+
A token is distinctive when it appears in at most 20 of the corpus's statements, which is under one percent of them. `.claude/plans/` sits at 14 and is the anchor the motivating case turns on, while `file` sits at 371 and would match most of the tree.
|
|
39
|
+
|
|
40
|
+
A backticked token counts double. An author marking a span as code named an identifier rather than describing one, so `.claude/plans/archive/` says more about what a statement governs than any two prose words do. Two statements are one rule when their shared anchors reach a weight of 3.
|
|
41
|
+
|
|
42
|
+
Every record names the anchors its match rested on, so a reader can weigh a finding instead of taking it.
|
|
43
|
+
|
|
44
|
+
## The three classes
|
|
45
|
+
|
|
46
|
+
- **Mirror.** Both files sit on a declared path pair whose duplication is deliberate. `CLAUDE.md` and the seed are the one pair, since the seed is authored from it and `claude-seed-sync` exists to reconcile the two. Excluding by pair rather than by content is the point: the duplication is a location fact this repository already records, and a content test would rediscover it on every run.
|
|
47
|
+
- **Repetition.** Two surfaces state one rule and neither is declared a copy of the other.
|
|
48
|
+
- **Contradiction.** The prohibition falls on one surface alone, on a match strong enough to read that as a disagreement. This is a polarity reading rather than a judgment about meaning, so weigh each against the surfaces it names.
|
|
49
|
+
|
|
50
|
+
The contradiction floor sits above the match floor deliberately. A thin match says two statements touch the same subject, which is not enough to claim one forbids what the other prescribes, so a weak pair reports as a repetition and the loudest class is reserved for a pair sharing real identity.
|
|
51
|
+
|
|
52
|
+
Two further rules decide where a prohibition counts, and both came out of false positives rather than from reasoning ahead of the corpus.
|
|
53
|
+
|
|
54
|
+
Polarity is read off the clause the anchors landed in, and off the densest such clause rather than every one carrying an anchor. A statement states one rule across several clauses, so a union answers true whenever any clause anywhere carries a marker, which is the whole statement again under another name.
|
|
55
|
+
|
|
56
|
+
The marker also has to open its clause, because an instruction leads with its verb. `Never delete a task file` prohibits where `a fallback never fires` reports, and no test reading the marker anywhere in the clause tells those apart. What that costs is a prohibition written mid-clause, which now reads as description and lands the pair in the repetition class, so both surfaces still reach the report and only the label is weaker.
|
|
57
|
+
|
|
58
|
+
A mirror that disagrees stays a finding. The exclusion reaches a repetition alone, because the two files on a declared pair are meant to agree.
|
|
59
|
+
|
|
60
|
+
## Which surface is authoritative
|
|
61
|
+
|
|
62
|
+
Each restatement names where a later edit starts.
|
|
63
|
+
|
|
64
|
+
- `claude-md` for a seed match, since the always-loaded file is authored first and the seed carries it to a target
|
|
65
|
+
- `skill-body` where the subject names that skill, which is the content-ownership rule that behavior triggered only when editing one domain belongs to that domain's skill
|
|
66
|
+
- `unknown` everywhere else, which is a first-class answer rather than a gap
|
|
67
|
+
|
|
68
|
+
The ownership table assigns a cross-domain rule and a domain-triggered one, and reaches nothing stated in a skill body the always-loaded file never names. Guessing there would put a reader on a surface nobody decided.
|
|
69
|
+
|
|
70
|
+
## Exit codes
|
|
71
|
+
|
|
72
|
+
Exit codes are `0` when no instruction is restated outside a declared mirror, `1` for a refusal, and `2` for at least one restatement outside one. Mirrors move no exit code.
|
|
73
|
+
|
|
74
|
+
Nothing wires this into `bun run check` or into a hook. A restatement is legitimate more often than not, and gating a measure whose ordinary result is a finding is what teaches contributors to route around the stage. `aitk gov test-order` and `aitk labels audit` are the siblings.
|
|
75
|
+
|
|
76
|
+
Both refusals are absences rather than breaks. A target holds neither the seed nor a shipped skills tree, so `aitk audits run` reads `no-instructions` and `no-surfaces` as a corpus that is not there rather than a verb that failed.
|
|
77
|
+
|
|
78
|
+
An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `counts` rather than the exit when a skill consumes this.
|
|
@@ -12,6 +12,8 @@ paths:
|
|
|
12
12
|
## Before editing
|
|
13
13
|
|
|
14
14
|
- Read the skill's sibling `REQUIREMENT.md` when one exists. If the change closes no gap it states, change the requirement first or drop the change.
|
|
15
|
+
- Confirm `create-skill`'s two creation-time questions are answered before a new `SKILL.md` lands, whether drafted by hand, by another skill, or by `create-skill` itself. Carry the third question into the sibling `REQUIREMENT.md`'s `Must not` section as a review criterion rather than a gate.
|
|
16
|
+
- Report it rather than proceeding silently when `create-skill` does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
|
|
15
17
|
|
|
16
18
|
## After editing
|
|
17
19
|
|
package/package.json
CHANGED
package/src/audits/catalog.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReachRefusal } from '@/claude/skills-reach'
|
|
2
2
|
import type { AuditRefusal } from '@/deps/audit'
|
|
3
|
+
import type { RestatedRefusal } from '@/gov/restated'
|
|
3
4
|
import type { LabelAuditRefusal } from '@/labels/audit'
|
|
4
5
|
import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
|
|
5
6
|
import type { ScanRefusal } from '@/secrets/scan'
|
|
@@ -356,6 +357,33 @@ function labelCoverageCounts(
|
|
|
356
357
|
return allOf({ uncovered: lengthOf(root.uncovered) })
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Reads the two classes that are findings, leaving the declared mirrors out.
|
|
362
|
+
*
|
|
363
|
+
* A mirror is an authoring root and its consumed copy, which duplicate on
|
|
364
|
+
* purpose, so folding them in would report a corpus getting worse every time a
|
|
365
|
+
* seed is kept in step with the file it is authored from. Same reasoning the
|
|
366
|
+
* label audit drops its declined rows on.
|
|
367
|
+
*
|
|
368
|
+
* The reach count is left out for a different reason. It counts how many
|
|
369
|
+
* surfaces an instruction reached rather than whether anything is wrong, and it
|
|
370
|
+
* moves in lockstep with the two retained here, so a baseline carrying it would
|
|
371
|
+
* report one movement twice.
|
|
372
|
+
*/
|
|
373
|
+
function restatedCounts(record: unknown): Record<string, number> | undefined {
|
|
374
|
+
const counts = asObject(asObject(record)?.counts)
|
|
375
|
+
if (counts === undefined) return undefined
|
|
376
|
+
|
|
377
|
+
return allOf({
|
|
378
|
+
contradictions:
|
|
379
|
+
typeof counts.contradictions === 'number'
|
|
380
|
+
? counts.contradictions
|
|
381
|
+
: undefined,
|
|
382
|
+
repetitions:
|
|
383
|
+
typeof counts.repetitions === 'number' ? counts.repetitions : undefined,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
359
387
|
function findingsOnly(record: unknown): Record<string, number> | undefined {
|
|
360
388
|
const root = asObject(record)
|
|
361
389
|
if (root === undefined) return undefined
|
|
@@ -548,6 +576,27 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
548
576
|
absentReasons: ['no-map'] satisfies LabelAuditRefusal[],
|
|
549
577
|
counts: labelCoverageCounts,
|
|
550
578
|
},
|
|
579
|
+
{
|
|
580
|
+
id: 'restated',
|
|
581
|
+
label: 'Restated instructions',
|
|
582
|
+
argv: ['gov', 'restated', '--json'],
|
|
583
|
+
// Reports rather than gates, on the split this file already draws. Whether
|
|
584
|
+
// a rule stated on two surfaces should be stated on one is a judgment the
|
|
585
|
+
// person owning the surface takes, and most restatements here are correct,
|
|
586
|
+
// so a push failing on one would fail on the ordinary case.
|
|
587
|
+
gatingExits: [],
|
|
588
|
+
corpus: 'tracked',
|
|
589
|
+
// Both reasons the sweep refuses for, and each is an absence rather than a
|
|
590
|
+
// break. A target holds neither the seed nor a shipped skills tree, so
|
|
591
|
+
// without the allowance every project installing this CLI reports the verb
|
|
592
|
+
// unmeasured on every run and never changes, which is the permanent signal
|
|
593
|
+
// the per-machine allowance exists against. Same shape as the reach verb.
|
|
594
|
+
absentReasons: [
|
|
595
|
+
'no-instructions',
|
|
596
|
+
'no-surfaces',
|
|
597
|
+
] satisfies RestatedRefusal[],
|
|
598
|
+
counts: restatedCounts,
|
|
599
|
+
},
|
|
551
600
|
{
|
|
552
601
|
id: 'deps',
|
|
553
602
|
label: 'Dependency advisories',
|
package/src/cli.ts
CHANGED
|
@@ -85,6 +85,7 @@ function showHelp(): void {
|
|
|
85
85
|
`${GREY}│${NC} aitk sandbox git:commit`,
|
|
86
86
|
`${GREY}│${NC} aitk gov install react`,
|
|
87
87
|
`${GREY}│${NC} aitk gov sync ../my-app`,
|
|
88
|
+
`${GREY}│${NC} aitk gov restated --json`,
|
|
88
89
|
`${GREY}│${NC} aitk standards markdown`,
|
|
89
90
|
`${GREY}│${NC} aitk snippets install base ../my-app`,
|
|
90
91
|
`${GREY}│${NC} aitk snippets sync ../my-app`,
|
package/src/commands/gov.ts
CHANGED
|
@@ -14,6 +14,15 @@ import {
|
|
|
14
14
|
mergeExtraRules,
|
|
15
15
|
resolveRules,
|
|
16
16
|
} from '@/gov/stacks'
|
|
17
|
+
import {
|
|
18
|
+
INSTRUCTIONS_REL,
|
|
19
|
+
type RestatedEntry,
|
|
20
|
+
type RestatedRefusal,
|
|
21
|
+
type RestatedReport,
|
|
22
|
+
readRestated,
|
|
23
|
+
SEED_REL,
|
|
24
|
+
SHIPPED_SKILLS_REL,
|
|
25
|
+
} from '@/gov/restated'
|
|
17
26
|
import {
|
|
18
27
|
readSuperseded,
|
|
19
28
|
SUPERSEDED_MARKER,
|
|
@@ -68,6 +77,17 @@ interface SupersededOptions {
|
|
|
68
77
|
readonly json?: boolean
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
interface RestatedOptions {
|
|
81
|
+
readonly root?: string
|
|
82
|
+
readonly json?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a reader does about each way the sweep produced no reading. */
|
|
86
|
+
const RESTATED_REFUSALS: Record<RestatedRefusal, string> = {
|
|
87
|
+
'no-instructions': `No ${INSTRUCTIONS_REL} here, or it carries no bullet, so there is no instruction corpus to sweep.`,
|
|
88
|
+
'no-surfaces': `Neither ${SEED_REL} nor ${SHIPPED_SKILLS_REL}/ is here, so no second surface exists to match against.`,
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
export function register(program: Command): void {
|
|
72
92
|
const gov = program
|
|
73
93
|
.command('gov')
|
|
@@ -245,6 +265,146 @@ export function register(program: Command): void {
|
|
|
245
265
|
process.exitCode = await runSuperseded(superseded, replacement, opts)
|
|
246
266
|
},
|
|
247
267
|
)
|
|
268
|
+
|
|
269
|
+
gov
|
|
270
|
+
.command('restated')
|
|
271
|
+
.description(
|
|
272
|
+
'Report every instruction the always-loaded file states that a second surface states too',
|
|
273
|
+
)
|
|
274
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
275
|
+
.option('--root <path>', 'Tree to read, defaulting to the cwd')
|
|
276
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
277
|
+
.addHelpText(
|
|
278
|
+
'after',
|
|
279
|
+
[
|
|
280
|
+
'',
|
|
281
|
+
`Matches every bullet in ${INSTRUCTIONS_REL} against ${SEED_REL}`,
|
|
282
|
+
`and every ${SHIPPED_SKILLS_REL}/*/SKILL.md body. Matching is recall-first,`,
|
|
283
|
+
'keyed on distinctive tokens two statements share rather than on a phrase',
|
|
284
|
+
'they spell the same way, because the case this exists for was one rule',
|
|
285
|
+
'written three different ways.',
|
|
286
|
+
'',
|
|
287
|
+
'What it separates:',
|
|
288
|
+
' mirror a declared authoring-to-consumed pair, where repeating is the design',
|
|
289
|
+
' repetition two surfaces state one rule and neither is declared a copy',
|
|
290
|
+
' contradiction the prohibition falls on one surface alone, on a strong match',
|
|
291
|
+
'',
|
|
292
|
+
'The contradiction class is a polarity reading rather than a judgment',
|
|
293
|
+
'about meaning, so weigh each against the surfaces it names.',
|
|
294
|
+
'',
|
|
295
|
+
'What it does not measure:',
|
|
296
|
+
' a rule stated in two skill bodies and never in the always-loaded file,',
|
|
297
|
+
' since the bullets there are the subjects and the bodies are searched',
|
|
298
|
+
'',
|
|
299
|
+
'Exit codes:',
|
|
300
|
+
' 0 no instruction is restated outside a declared mirror',
|
|
301
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
302
|
+
' 2 at least one instruction is restated outside a declared mirror',
|
|
303
|
+
'',
|
|
304
|
+
'Examples:',
|
|
305
|
+
' aitk gov restated',
|
|
306
|
+
' aitk gov restated --json',
|
|
307
|
+
'',
|
|
308
|
+
].join('\n'),
|
|
309
|
+
)
|
|
310
|
+
.action((opts: RestatedOptions) => {
|
|
311
|
+
process.exitCode = runRestated(opts)
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Reports and never gates, matching the two sweeps above. A restatement is
|
|
317
|
+
* legitimate more often than not, so failing a push on one would fail on the
|
|
318
|
+
* ordinary case and teach contributors to route around the stage.
|
|
319
|
+
*/
|
|
320
|
+
function runRestated(opts: RestatedOptions): number {
|
|
321
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
322
|
+
const report = readRestated(root)
|
|
323
|
+
const emitJson = opts.json ?? false
|
|
324
|
+
|
|
325
|
+
if (report.kind === 'unreadable') {
|
|
326
|
+
intro('aitk gov restated')
|
|
327
|
+
logStep('Refused')
|
|
328
|
+
logWarn(RESTATED_REFUSALS[report.reason])
|
|
329
|
+
outro()
|
|
330
|
+
|
|
331
|
+
if (emitJson) {
|
|
332
|
+
process.stdout.write(
|
|
333
|
+
`${JSON.stringify({
|
|
334
|
+
root,
|
|
335
|
+
reason: report.reason,
|
|
336
|
+
message: RESTATED_REFUSALS[report.reason],
|
|
337
|
+
})}\n`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return 1
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
reportRestated(report, root)
|
|
345
|
+
|
|
346
|
+
if (emitJson) {
|
|
347
|
+
process.stdout.write(`${JSON.stringify({ root, ...report })}\n`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const findings = report.counts.contradictions + report.counts.repetitions
|
|
351
|
+
return findings > 0 ? 2 : 0
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function describeEntry(entry: RestatedEntry): string[] {
|
|
355
|
+
const lines = [`${entry.subject.file}:${entry.subject.line}`]
|
|
356
|
+
|
|
357
|
+
for (const surface of entry.surfaces) {
|
|
358
|
+
lines.push(
|
|
359
|
+
` [${surface.restatement}] ${surface.file}:${surface.line} via ${surface.anchors.join(', ')}`,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return lines
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function reportRestated(
|
|
367
|
+
report: Extract<RestatedReport, { kind: 'measured' }>,
|
|
368
|
+
root: string,
|
|
369
|
+
): void {
|
|
370
|
+
intro('aitk gov restated')
|
|
371
|
+
|
|
372
|
+
// A count of what matched reads as a verdict on the repository unless the run
|
|
373
|
+
// also says how wide the corpus behind it was.
|
|
374
|
+
logStep('Corpus')
|
|
375
|
+
logInfo(
|
|
376
|
+
`${report.corpus.instructions} instruction(s) against ${report.corpus.candidates} statement(s) from the seed and ${report.corpus.bodies} shipped body/bodies in ${root}`,
|
|
377
|
+
)
|
|
378
|
+
logInfo(
|
|
379
|
+
`matched on ${report.matcher.anchors} weighted anchor(s), dropping any token in more than ${report.matcher.common} statements`,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
// Named rather than counted. A polarity split is the only class claiming a
|
|
383
|
+
// defect, and a reader weighing one has to reach both surfaces.
|
|
384
|
+
logStep(
|
|
385
|
+
report.counts.contradictions === 0 ? 'No contradiction' : 'Contradictions',
|
|
386
|
+
)
|
|
387
|
+
if (report.counts.contradictions === 0) {
|
|
388
|
+
logInfo('no restatement puts a prohibition on one surface alone')
|
|
389
|
+
} else {
|
|
390
|
+
for (const entry of report.restatements) {
|
|
391
|
+
const carries = entry.surfaces.some(
|
|
392
|
+
(surface) => surface.restatement === 'contradiction',
|
|
393
|
+
)
|
|
394
|
+
if (!carries) continue
|
|
395
|
+
for (const line of describeEntry(entry)) logWarn(line)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
logStep('Restated')
|
|
400
|
+
logInfo(
|
|
401
|
+
`${report.counts.repetitions} repetition(s) outside a declared mirror, and ${report.counts.mirrors} on one`,
|
|
402
|
+
)
|
|
403
|
+
logInfo(
|
|
404
|
+
`${report.counts.threeSurface} instruction(s) reach three surfaces or more`,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
outro()
|
|
248
408
|
}
|
|
249
409
|
|
|
250
410
|
/**
|
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** The always-loaded file whose bullets are the subjects this sweep matches. */
|
|
5
|
+
export const INSTRUCTIONS_REL = 'CLAUDE.md'
|
|
6
|
+
|
|
7
|
+
/** The seed a target receives, authored from the file above. */
|
|
8
|
+
export const SEED_REL = join('tooling', 'claude', 'seeds', 'CLAUDE.md')
|
|
9
|
+
|
|
10
|
+
/** The shipped plugin bodies, which is where a rule restated in prose lands. */
|
|
11
|
+
export const SHIPPED_SKILLS_REL = join('claude', 'skills')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Path pairs whose duplication is deliberate and already recorded.
|
|
15
|
+
*
|
|
16
|
+
* The seed is authored from the always-loaded file and `claude-seed-sync`
|
|
17
|
+
* exists to reconcile the two, so a bullet appearing in both is the design
|
|
18
|
+
* rather than a defect. Excluding by pair rather than by content is what the
|
|
19
|
+
* plan settled on: the duplication is a location fact this repository already
|
|
20
|
+
* records, and a content test would have to rediscover it on every run.
|
|
21
|
+
*
|
|
22
|
+
* The exclusion reaches a repetition alone. A mirror that disagrees is the one
|
|
23
|
+
* shape the pairing cannot absorb, since the two files are meant to agree, so
|
|
24
|
+
* a polarity split on a declared pair stays a finding.
|
|
25
|
+
*/
|
|
26
|
+
const MIRRORS: readonly (readonly [string, string])[] = [
|
|
27
|
+
[INSTRUCTIONS_REL, SEED_REL],
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A token appearing in more than this many statements carries no signal.
|
|
32
|
+
*
|
|
33
|
+
* Under one percent of the 2750 statements this repository offers. Tuned
|
|
34
|
+
* against that corpus rather than reasoned to, which is what the plan asked of
|
|
35
|
+
* the first run: `.claude/plans/` sits at 14 and is the anchor the motivating
|
|
36
|
+
* case turns on, while `file` sits at 371 and matches most of the tree.
|
|
37
|
+
*/
|
|
38
|
+
export const COMMON_CEILING = 20
|
|
39
|
+
|
|
40
|
+
/** Weight two statements must share before they are read as one rule. */
|
|
41
|
+
export const ANCHOR_FLOOR = 3
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a backticked token is worth against a plain word.
|
|
45
|
+
*
|
|
46
|
+
* An author marking a span as code named an identifier rather than describing
|
|
47
|
+
* one, so `.claude/plans/archive/` says more about what a statement governs
|
|
48
|
+
* than any two prose words do. Weighting it is what lets the floor rise high
|
|
49
|
+
* enough to drop a coincidental word pair without losing a rule two surfaces
|
|
50
|
+
* spelled entirely differently around one shared path.
|
|
51
|
+
*/
|
|
52
|
+
const SPAN_WEIGHT = 2
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Weight a match needs before a polarity split is called a contradiction.
|
|
56
|
+
*
|
|
57
|
+
* Above the match floor on purpose. A thin match says two statements touch the
|
|
58
|
+
* same subject, which is not enough to claim one forbids what the other
|
|
59
|
+
* prescribes, so a weak pair reports as a repetition and the loudest class is
|
|
60
|
+
* reserved for a pair sharing real identity.
|
|
61
|
+
*/
|
|
62
|
+
export const CONTRADICTION_FLOOR = 5
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Words carrying no subject, dropped before anchors are counted.
|
|
66
|
+
*
|
|
67
|
+
* Short rather than exhaustive. The document-frequency ceiling above removes
|
|
68
|
+
* the rest on its own, and a hand-written list long enough to do that job
|
|
69
|
+
* would be a second corpus nobody maintains.
|
|
70
|
+
*/
|
|
71
|
+
const STOPWORDS = new Set([
|
|
72
|
+
'about',
|
|
73
|
+
'after',
|
|
74
|
+
'against',
|
|
75
|
+
'already',
|
|
76
|
+
'also',
|
|
77
|
+
'and',
|
|
78
|
+
'any',
|
|
79
|
+
'are',
|
|
80
|
+
'because',
|
|
81
|
+
'been',
|
|
82
|
+
'before',
|
|
83
|
+
'being',
|
|
84
|
+
'both',
|
|
85
|
+
'but',
|
|
86
|
+
'can',
|
|
87
|
+
'each',
|
|
88
|
+
'either',
|
|
89
|
+
'else',
|
|
90
|
+
'every',
|
|
91
|
+
'for',
|
|
92
|
+
'from',
|
|
93
|
+
'has',
|
|
94
|
+
'have',
|
|
95
|
+
'her',
|
|
96
|
+
'here',
|
|
97
|
+
'his',
|
|
98
|
+
'how',
|
|
99
|
+
'into',
|
|
100
|
+
'its',
|
|
101
|
+
'itself',
|
|
102
|
+
'more',
|
|
103
|
+
'most',
|
|
104
|
+
'much',
|
|
105
|
+
'must',
|
|
106
|
+
'once',
|
|
107
|
+
'one',
|
|
108
|
+
'only',
|
|
109
|
+
'other',
|
|
110
|
+
'our',
|
|
111
|
+
'out',
|
|
112
|
+
'over',
|
|
113
|
+
'own',
|
|
114
|
+
'per',
|
|
115
|
+
'rather',
|
|
116
|
+
'same',
|
|
117
|
+
'she',
|
|
118
|
+
'should',
|
|
119
|
+
'since',
|
|
120
|
+
'some',
|
|
121
|
+
'such',
|
|
122
|
+
'than',
|
|
123
|
+
'that',
|
|
124
|
+
'the',
|
|
125
|
+
'their',
|
|
126
|
+
'them',
|
|
127
|
+
'then',
|
|
128
|
+
'there',
|
|
129
|
+
'these',
|
|
130
|
+
'they',
|
|
131
|
+
'this',
|
|
132
|
+
'those',
|
|
133
|
+
'through',
|
|
134
|
+
'too',
|
|
135
|
+
'under',
|
|
136
|
+
'until',
|
|
137
|
+
'upon',
|
|
138
|
+
'very',
|
|
139
|
+
'was',
|
|
140
|
+
'were',
|
|
141
|
+
'what',
|
|
142
|
+
'when',
|
|
143
|
+
'where',
|
|
144
|
+
'which',
|
|
145
|
+
'while',
|
|
146
|
+
'who',
|
|
147
|
+
'whose',
|
|
148
|
+
'why',
|
|
149
|
+
'will',
|
|
150
|
+
'with',
|
|
151
|
+
'would',
|
|
152
|
+
'you',
|
|
153
|
+
'your',
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Markers reading as a prohibition, which is the one polarity signal available
|
|
158
|
+
* without understanding the sentence.
|
|
159
|
+
*
|
|
160
|
+
* Deliberately narrow. `no` and `not` are excluded because both appear inside
|
|
161
|
+
* ordinary qualifying clauses, and widening the set turns most of the corpus
|
|
162
|
+
* into a suspected contradiction.
|
|
163
|
+
*/
|
|
164
|
+
const PROHIBITIONS = ['never', 'do not', "don't", 'avoid', 'refuse']
|
|
165
|
+
|
|
166
|
+
export type RestatedRefusal = 'no-instructions' | 'no-surfaces'
|
|
167
|
+
|
|
168
|
+
export type Restatement = 'mirror' | 'repetition' | 'contradiction'
|
|
169
|
+
|
|
170
|
+
/** Which surface a restatement was found on, before any class is assigned. */
|
|
171
|
+
export type SurfaceKind = 'seed' | 'skill'
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Which surface a later edit starts from.
|
|
175
|
+
*
|
|
176
|
+
* `unknown` is a first-class answer rather than a gap. The content-ownership
|
|
177
|
+
* table assigns a cross-domain rule and a domain-triggered one, and reaches
|
|
178
|
+
* nothing stated in a skill body the always-loaded file never names, so
|
|
179
|
+
* guessing there would put a reader on a surface nobody decided.
|
|
180
|
+
*/
|
|
181
|
+
export type Authority = 'claude-md' | 'skill-body' | 'unknown'
|
|
182
|
+
|
|
183
|
+
export interface Statement {
|
|
184
|
+
/** Repository-relative, so a record reads the same from any working root. */
|
|
185
|
+
readonly file: string
|
|
186
|
+
/** One-based, matching the `file:line` form a reader clicks. */
|
|
187
|
+
readonly line: number
|
|
188
|
+
readonly text: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface Surface extends Statement {
|
|
192
|
+
readonly kind: SurfaceKind
|
|
193
|
+
readonly restatement: Restatement
|
|
194
|
+
/** The distinctive tokens this match rested on, so a finding is auditable. */
|
|
195
|
+
readonly anchors: readonly string[]
|
|
196
|
+
/** Those anchors scored, with a backticked one counting double. */
|
|
197
|
+
readonly weight: number
|
|
198
|
+
readonly authority: Authority
|
|
199
|
+
/** Why the class and the authority read the way they do. */
|
|
200
|
+
readonly reason: string
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface RestatedEntry {
|
|
204
|
+
readonly subject: Statement
|
|
205
|
+
readonly surfaces: readonly Surface[]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface RestatedCounts {
|
|
209
|
+
readonly contradictions: number
|
|
210
|
+
readonly repetitions: number
|
|
211
|
+
readonly mirrors: number
|
|
212
|
+
/** Subjects carried by two further surfaces, which is the title's count. */
|
|
213
|
+
readonly threeSurface: number
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type RestatedReport =
|
|
217
|
+
| {
|
|
218
|
+
readonly kind: 'measured'
|
|
219
|
+
readonly corpus: {
|
|
220
|
+
readonly instructions: number
|
|
221
|
+
readonly seed: number
|
|
222
|
+
readonly bodies: number
|
|
223
|
+
/** Statements the two further surfaces offered, which bounds recall. */
|
|
224
|
+
readonly candidates: number
|
|
225
|
+
}
|
|
226
|
+
readonly matcher: {
|
|
227
|
+
readonly anchors: number
|
|
228
|
+
readonly common: number
|
|
229
|
+
readonly contradiction: number
|
|
230
|
+
}
|
|
231
|
+
readonly restatements: readonly RestatedEntry[]
|
|
232
|
+
readonly counts: RestatedCounts
|
|
233
|
+
}
|
|
234
|
+
| { readonly kind: 'unreadable'; readonly reason: RestatedRefusal }
|
|
235
|
+
|
|
236
|
+
interface Candidate extends Statement {
|
|
237
|
+
readonly kind: SurfaceKind
|
|
238
|
+
/** The skill folder this statement sits in, present on a body alone. */
|
|
239
|
+
readonly skill?: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface Analysis {
|
|
243
|
+
readonly tokens: ReadonlySet<string>
|
|
244
|
+
/** The subset an author backticked, which weighs more than a plain word. */
|
|
245
|
+
readonly spans: ReadonlySet<string>
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A statement paired with the tokens it carries. */
|
|
249
|
+
interface Indexed<T extends Statement> {
|
|
250
|
+
readonly statement: T
|
|
251
|
+
readonly analysis: Analysis
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Splits text into the tokens an anchor can be drawn from.
|
|
256
|
+
*
|
|
257
|
+
* A code span keeps its inner text whole, since `.claude/plans/archive/` is the
|
|
258
|
+
* strongest anchor this corpus offers and splitting it on the punctuation would
|
|
259
|
+
* leave three words every second bullet also carries.
|
|
260
|
+
*/
|
|
261
|
+
export function analyze(text: string): Analysis {
|
|
262
|
+
const spans: string[] = []
|
|
263
|
+
const withoutSpans = text.replace(/`([^`]+)`/g, (_match, inner: string) => {
|
|
264
|
+
spans.push(inner.toLowerCase())
|
|
265
|
+
return ' '
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const words = withoutSpans
|
|
269
|
+
.toLowerCase()
|
|
270
|
+
.replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2')
|
|
271
|
+
.split(/[^a-z0-9/._<>-]+/)
|
|
272
|
+
.map((word) => word.replace(/^[-._/]+|[-._/,;:]+$/g, ''))
|
|
273
|
+
|
|
274
|
+
const keep = (token: string): boolean =>
|
|
275
|
+
token.length >= 3 && !STOPWORDS.has(token)
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
tokens: new Set([...spans, ...words].filter(keep)),
|
|
279
|
+
spans: new Set(spans.filter(keep)),
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether a clause instructs against something, rather than merely describing
|
|
285
|
+
* something that does not happen.
|
|
286
|
+
*
|
|
287
|
+
* The marker has to open the clause. A prohibition is an instruction, and an
|
|
288
|
+
* instruction leads with its verb, so `Never delete a task file` prohibits
|
|
289
|
+
* where `a fallback never fires` reports. Reading the marker anywhere in the
|
|
290
|
+
* clause cannot separate those two, and this corpus writes both: the third
|
|
291
|
+
* false contradiction found here was `so a || fallback never fires` against a
|
|
292
|
+
* seed clause saying the same thing in other words.
|
|
293
|
+
*
|
|
294
|
+
* What it costs is a prohibition written mid-clause, as in `edit with the tool,
|
|
295
|
+
* never a stream editor`, which now reads as description. That miss lands the
|
|
296
|
+
* pair in the repetition class rather than dropping it, so both surfaces still
|
|
297
|
+
* reach the report and only the label is weaker.
|
|
298
|
+
*/
|
|
299
|
+
function prohibits(text: string): boolean {
|
|
300
|
+
const opening = text
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.replace(/^[^a-z]*/, '')
|
|
303
|
+
.replace(/^(and|but|so|then|also|however)[\s,]+/, '')
|
|
304
|
+
|
|
305
|
+
return PROHIBITIONS.some((marker) => opening.startsWith(marker))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The clauses a statement's polarity is read against.
|
|
310
|
+
*
|
|
311
|
+
* The trailing span is kept, where `splitSentences` in `src/markdown/structure.ts`
|
|
312
|
+
* drops one no punctuation closes. A bullet routinely ends without a period and
|
|
313
|
+
* its last clause is routinely the one carrying the prohibition, so dropping it
|
|
314
|
+
* would lose exactly the half this reads. The two contracts differ, which is why
|
|
315
|
+
* this is a second splitter rather than a shared one.
|
|
316
|
+
*/
|
|
317
|
+
function clauses(text: string): string[] {
|
|
318
|
+
return text
|
|
319
|
+
.split(/(?<=[.!?])\s+/)
|
|
320
|
+
.map((clause) => clause.trim())
|
|
321
|
+
.filter((clause) => clause !== '')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The clause a match landed in, which is where a prohibition has to sit before
|
|
326
|
+
* it says anything about the rule the two statements share.
|
|
327
|
+
*
|
|
328
|
+
* The densest clause rather than every clause carrying an anchor. A statement
|
|
329
|
+
* states one rule across several clauses, and a union answers true whenever any
|
|
330
|
+
* clause anywhere carries a marker, which is the whole statement again under
|
|
331
|
+
* another name.
|
|
332
|
+
*
|
|
333
|
+
* Both contradictions this repository reported were that defect. The
|
|
334
|
+
* always-loaded file splits the stream-editor rule across two bullets and the
|
|
335
|
+
* seed folds them into one, so the subject was the exception half alone while
|
|
336
|
+
* the seed's bullet carried the `never` from a clause the anchors never
|
|
337
|
+
* touched, and the two agreed completely.
|
|
338
|
+
*/
|
|
339
|
+
function anchoredClause(text: string, anchors: ReadonlySet<string>): string {
|
|
340
|
+
const parts = clauses(text)
|
|
341
|
+
if (parts.length <= 1) return text
|
|
342
|
+
|
|
343
|
+
let best = text
|
|
344
|
+
let bestHits = 0
|
|
345
|
+
|
|
346
|
+
for (const part of parts) {
|
|
347
|
+
const tokens = analyze(part).tokens
|
|
348
|
+
let hits = 0
|
|
349
|
+
for (const anchor of anchors) if (tokens.has(anchor)) hits += 1
|
|
350
|
+
|
|
351
|
+
if (hits > bestHits) {
|
|
352
|
+
bestHits = hits
|
|
353
|
+
best = part
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// No clause carries an anchor, which a boundary landing inside a code span
|
|
358
|
+
// can produce. Reading the whole statement is what this did before the clause
|
|
359
|
+
// scope, so it degrades to that rather than to no polarity at all.
|
|
360
|
+
return bestHits === 0 ? text : best
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isMirrorPair(subject: string, surface: string): boolean {
|
|
364
|
+
return MIRRORS.some(
|
|
365
|
+
([left, right]) =>
|
|
366
|
+
(subject === left && surface === right) ||
|
|
367
|
+
(subject === right && surface === left),
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Bullets at the top level of a markdown file, which is the unit an instruction
|
|
373
|
+
* takes in the always-loaded file and in the seed.
|
|
374
|
+
*/
|
|
375
|
+
function readBullets(root: string, relative: string): Statement[] {
|
|
376
|
+
const full = join(root, relative)
|
|
377
|
+
if (!existsSync(full)) return []
|
|
378
|
+
|
|
379
|
+
const statements: Statement[] = []
|
|
380
|
+
let fenced = false
|
|
381
|
+
|
|
382
|
+
readFileSync(full, 'utf8')
|
|
383
|
+
.split('\n')
|
|
384
|
+
.forEach((line, index) => {
|
|
385
|
+
if (line.trimStart().startsWith('```')) {
|
|
386
|
+
fenced = !fenced
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (fenced || !line.startsWith('- ')) return
|
|
390
|
+
|
|
391
|
+
statements.push({
|
|
392
|
+
file: relative.replaceAll('\\', '/'),
|
|
393
|
+
line: index + 1,
|
|
394
|
+
text: line.slice(2).trim(),
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
return statements
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Every prose line and bullet in a shipped body.
|
|
403
|
+
*
|
|
404
|
+
* Wider than the bullet rule above because the motivating case was stated in a
|
|
405
|
+
* body as a paragraph, so a bullet-only read would miss the one instance this
|
|
406
|
+
* sweep exists for. Headings, tables, and fenced blocks are read past: a
|
|
407
|
+
* heading names a section rather than stating a rule, and a fenced block is an
|
|
408
|
+
* example whose words are the surrounding prose's by construction.
|
|
409
|
+
*/
|
|
410
|
+
function readBodyLines(root: string, skillsRoot: string): Candidate[] {
|
|
411
|
+
const candidates: Candidate[] = []
|
|
412
|
+
|
|
413
|
+
const files = [
|
|
414
|
+
...new Bun.Glob('*/SKILL.md').scanSync({
|
|
415
|
+
cwd: skillsRoot,
|
|
416
|
+
onlyFiles: true,
|
|
417
|
+
}),
|
|
418
|
+
].sort()
|
|
419
|
+
|
|
420
|
+
for (const file of files) {
|
|
421
|
+
const posix = file.replaceAll('\\', '/')
|
|
422
|
+
const skill = posix.split('/')[0]
|
|
423
|
+
const relative = `${SHIPPED_SKILLS_REL.replaceAll('\\', '/')}/${posix}`
|
|
424
|
+
|
|
425
|
+
const lines = readFileSync(join(root, relative), 'utf8').split('\n')
|
|
426
|
+
let fenced = false
|
|
427
|
+
let frontmatter = lines[0]?.trim() === '---'
|
|
428
|
+
|
|
429
|
+
for (const [index, line] of lines.entries()) {
|
|
430
|
+
const trimmed = line.trim()
|
|
431
|
+
|
|
432
|
+
// Frontmatter is metadata rather than instruction, and every body's
|
|
433
|
+
// `description` restates that skill's own purpose, so sweeping it makes
|
|
434
|
+
// each skill match any subject naming its domain.
|
|
435
|
+
if (frontmatter) {
|
|
436
|
+
if (index > 0 && trimmed === '---') frontmatter = false
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (trimmed.startsWith('```')) {
|
|
441
|
+
fenced = !fenced
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
if (fenced || trimmed === '') continue
|
|
445
|
+
if (trimmed.startsWith('#') || trimmed.startsWith('|')) continue
|
|
446
|
+
if (trimmed.startsWith('---')) continue
|
|
447
|
+
|
|
448
|
+
candidates.push({
|
|
449
|
+
file: relative,
|
|
450
|
+
line: index + 1,
|
|
451
|
+
text: trimmed.replace(/^[-*>]\s+/, ''),
|
|
452
|
+
kind: 'skill',
|
|
453
|
+
skill,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return candidates
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** How many statements each token appears in, which is what rarity is read off. */
|
|
462
|
+
function documentFrequency(
|
|
463
|
+
groups: readonly (readonly Indexed<Statement>[])[],
|
|
464
|
+
): Map<string, number> {
|
|
465
|
+
const frequency = new Map<string, number>()
|
|
466
|
+
|
|
467
|
+
for (const group of groups) {
|
|
468
|
+
for (const entry of group) {
|
|
469
|
+
for (const token of entry.analysis.tokens) {
|
|
470
|
+
frequency.set(token, (frequency.get(token) ?? 0) + 1)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return frequency
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function index<T extends Statement>(statement: T): Indexed<T> {
|
|
479
|
+
return { statement, analysis: analyze(statement.text) }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Which surface a later edit starts from, and why.
|
|
484
|
+
*
|
|
485
|
+
* A skill body earns authority only where the subject names that skill, which
|
|
486
|
+
* is the content-ownership table's rule that behavior triggered when editing
|
|
487
|
+
* domain X belongs to X's skill. Everything else the table does not reach is
|
|
488
|
+
* reported as unknown.
|
|
489
|
+
*/
|
|
490
|
+
function authorityFor(
|
|
491
|
+
subject: Statement,
|
|
492
|
+
candidate: Candidate,
|
|
493
|
+
): { authority: Authority; reason: string } {
|
|
494
|
+
if (candidate.kind === 'seed') {
|
|
495
|
+
return {
|
|
496
|
+
authority: 'claude-md',
|
|
497
|
+
reason: `${INSTRUCTIONS_REL} is authored first and the seed carries it to a target, so an edit starts there and reaches the seed through claude-seed-sync`,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (
|
|
502
|
+
candidate.skill !== undefined &&
|
|
503
|
+
subject.text.toLowerCase().includes(candidate.skill.toLowerCase())
|
|
504
|
+
) {
|
|
505
|
+
return {
|
|
506
|
+
authority: 'skill-body',
|
|
507
|
+
reason: `the subject names ${candidate.skill}, and behavior triggered only when editing one domain belongs to that domain's skill`,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
authority: 'unknown',
|
|
513
|
+
reason:
|
|
514
|
+
'the content-ownership table assigns a cross-domain rule and a domain-triggered one, and reaches neither from here',
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function classify(
|
|
519
|
+
subject: Statement,
|
|
520
|
+
candidate: Candidate,
|
|
521
|
+
weight: number,
|
|
522
|
+
anchors: ReadonlySet<string>,
|
|
523
|
+
): { restatement: Restatement; reason: string } {
|
|
524
|
+
const split =
|
|
525
|
+
prohibits(anchoredClause(subject.text, anchors)) !==
|
|
526
|
+
prohibits(anchoredClause(candidate.text, anchors))
|
|
527
|
+
|
|
528
|
+
if (split && weight >= CONTRADICTION_FLOOR) {
|
|
529
|
+
return {
|
|
530
|
+
restatement: 'contradiction',
|
|
531
|
+
reason:
|
|
532
|
+
'the clause each surface was matched on states this as a prohibition on one side alone, which is a polarity reading rather than a judgment about meaning',
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (isMirrorPair(subject.file, candidate.file)) {
|
|
537
|
+
return {
|
|
538
|
+
restatement: 'mirror',
|
|
539
|
+
reason:
|
|
540
|
+
'both files sit on a declared mirror pair, where repeating the rule is the design',
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
restatement: 'repetition',
|
|
546
|
+
reason: split
|
|
547
|
+
? 'the matched clause carries a prohibition on one surface alone, on a match too thin to read that as a disagreement'
|
|
548
|
+
: 'two surfaces state one rule and neither is declared a copy of the other',
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Every instruction in the always-loaded file that a second surface also states.
|
|
554
|
+
*
|
|
555
|
+
* Matching is recall-first, keyed on distinctive tokens two statements share
|
|
556
|
+
* rather than on a phrase they spell the same way. The motivating case was one
|
|
557
|
+
* rule written three different ways, so a near-exact matcher would miss the
|
|
558
|
+
* defect the sweep exists for, and a recall-first reading can be narrowed from
|
|
559
|
+
* real output where the reverse cannot.
|
|
560
|
+
*
|
|
561
|
+
* It reports and never gates. A restatement is legitimate more often than not,
|
|
562
|
+
* so a push failing on one would fail on the ordinary case.
|
|
563
|
+
*/
|
|
564
|
+
export function readRestated(root: string): RestatedReport {
|
|
565
|
+
const instructions = readBullets(root, INSTRUCTIONS_REL)
|
|
566
|
+
if (instructions.length === 0) {
|
|
567
|
+
return { kind: 'unreadable', reason: 'no-instructions' }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const seed: Candidate[] = readBullets(root, SEED_REL).map((statement) => ({
|
|
571
|
+
...statement,
|
|
572
|
+
kind: 'seed' as const,
|
|
573
|
+
}))
|
|
574
|
+
|
|
575
|
+
const skillsRoot = join(root, SHIPPED_SKILLS_REL)
|
|
576
|
+
const bodies = existsSync(skillsRoot) ? readBodyLines(root, skillsRoot) : []
|
|
577
|
+
|
|
578
|
+
const bodyFiles = new Set(bodies.map((candidate) => candidate.file)).size
|
|
579
|
+
if (seed.length === 0 && bodies.length === 0) {
|
|
580
|
+
return { kind: 'unreadable', reason: 'no-surfaces' }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const subjects = instructions.map(index)
|
|
584
|
+
const candidates = [...seed, ...bodies].map(index)
|
|
585
|
+
const frequency = documentFrequency([subjects, candidates])
|
|
586
|
+
|
|
587
|
+
const distinctive = (analysis: Analysis): Set<string> =>
|
|
588
|
+
new Set(
|
|
589
|
+
[...analysis.tokens].filter(
|
|
590
|
+
(token) => (frequency.get(token) ?? 0) <= COMMON_CEILING,
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
// Every candidate's rare set is invariant across the subject loop, so it is
|
|
595
|
+
// built once here rather than per pair. The corpora multiply out to hundreds
|
|
596
|
+
// of thousands of pairings, and rebuilding a set inside that is the shape
|
|
597
|
+
// `.claude/rules/core/040-performance.md` names.
|
|
598
|
+
const rareCandidates = candidates.map((candidate) => ({
|
|
599
|
+
...candidate,
|
|
600
|
+
rare: distinctive(candidate.analysis),
|
|
601
|
+
}))
|
|
602
|
+
|
|
603
|
+
const restatements: RestatedEntry[] = []
|
|
604
|
+
let contradictions = 0
|
|
605
|
+
let repetitions = 0
|
|
606
|
+
let mirrors = 0
|
|
607
|
+
let threeSurface = 0
|
|
608
|
+
|
|
609
|
+
for (const subject of subjects) {
|
|
610
|
+
const rare = distinctive(subject.analysis)
|
|
611
|
+
const surfaces: Surface[] = []
|
|
612
|
+
|
|
613
|
+
for (const candidate of rareCandidates) {
|
|
614
|
+
const shared = [...candidate.rare]
|
|
615
|
+
.filter((token) => rare.has(token))
|
|
616
|
+
.sort()
|
|
617
|
+
|
|
618
|
+
const weight = shared.reduce(
|
|
619
|
+
(total, token) =>
|
|
620
|
+
total +
|
|
621
|
+
(subject.analysis.spans.has(token) &&
|
|
622
|
+
candidate.analysis.spans.has(token)
|
|
623
|
+
? SPAN_WEIGHT
|
|
624
|
+
: 1),
|
|
625
|
+
0,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if (weight < ANCHOR_FLOOR) continue
|
|
629
|
+
|
|
630
|
+
const { restatement, reason } = classify(
|
|
631
|
+
subject.statement,
|
|
632
|
+
candidate.statement,
|
|
633
|
+
weight,
|
|
634
|
+
new Set(shared),
|
|
635
|
+
)
|
|
636
|
+
const { authority, reason: why } = authorityFor(
|
|
637
|
+
subject.statement,
|
|
638
|
+
candidate.statement,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
surfaces.push({
|
|
642
|
+
file: candidate.statement.file,
|
|
643
|
+
line: candidate.statement.line,
|
|
644
|
+
text: candidate.statement.text,
|
|
645
|
+
kind: candidate.statement.kind,
|
|
646
|
+
restatement,
|
|
647
|
+
anchors: shared,
|
|
648
|
+
weight,
|
|
649
|
+
authority,
|
|
650
|
+
reason: `${reason}; ${why}`,
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (surfaces.length === 0) continue
|
|
655
|
+
|
|
656
|
+
for (const surface of surfaces) {
|
|
657
|
+
if (surface.restatement === 'contradiction') contradictions += 1
|
|
658
|
+
else if (surface.restatement === 'mirror') mirrors += 1
|
|
659
|
+
else repetitions += 1
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Every surface counts here, a declared mirror included. The motivating
|
|
663
|
+
// case was the always-loaded file, the seed, and a body, so dropping the
|
|
664
|
+
// mirror would read that exact shape as a rule stated twice. The mirror
|
|
665
|
+
// exclusion is a rule about which class is a finding, not about how far an
|
|
666
|
+
// instruction reached.
|
|
667
|
+
if (surfaces.length >= 2) threeSurface += 1
|
|
668
|
+
|
|
669
|
+
restatements.push({ subject: subject.statement, surfaces })
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
kind: 'measured',
|
|
674
|
+
corpus: {
|
|
675
|
+
instructions: instructions.length,
|
|
676
|
+
seed: seed.length,
|
|
677
|
+
bodies: bodyFiles,
|
|
678
|
+
candidates: candidates.length,
|
|
679
|
+
},
|
|
680
|
+
matcher: {
|
|
681
|
+
anchors: ANCHOR_FLOOR,
|
|
682
|
+
common: COMMON_CEILING,
|
|
683
|
+
contradiction: CONTRADICTION_FLOOR,
|
|
684
|
+
},
|
|
685
|
+
restatements,
|
|
686
|
+
counts: { contradictions, repetitions, mirrors, threeSurface },
|
|
687
|
+
}
|
|
688
|
+
}
|
package/standards/skill.md
CHANGED
|
@@ -21,6 +21,7 @@ Does not govern:
|
|
|
21
21
|
- Punctuation, formatting, and word choice in a skill body: `markdown.md`
|
|
22
22
|
- The transform from a branch name to a slug a skill carries in a filename: `slug.md`
|
|
23
23
|
- The domain conventions a skill cites, each of which belongs to the standard that owns it
|
|
24
|
+
- Whether a new skill earns its place: the three-question test in the `create-skill` skill and the clause in `570-skill.md`
|
|
24
25
|
|
|
25
26
|
## Changing a skill
|
|
26
27
|
|