@erclx/aitk 0.107.1 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/SKILL.md +2 -0
- package/claude/skills/claude-orchestrate/SKILL.md +1 -1
- package/claude/skills/claude-tasks/SKILL.md +4 -2
- package/claude/skills/cli-script/SKILL.md +1 -0
- package/claude/skills/project-commands/SKILL.md +2 -0
- package/claude/skills/setup-indexes/SKILL.md +2 -2
- package/docs/agents/context-audit-checks.md +5 -1
- package/docs/agents/indexes.md +2 -0
- package/docs/agents/intake.md +2 -0
- package/docs/agents/markdown-audit.md +24 -2
- package/docs/agents/records.md +2 -0
- package/docs/agents/sessions.md +2 -0
- package/docs/agents/skills-audit.md +7 -1
- package/docs/operating-model.md +1 -1
- package/governance/rules/claude/570-skill.md +5 -0
- package/package.json +1 -1
- package/src/commands/context.ts +5 -3
- package/src/commands/markdown.ts +104 -1
- package/src/context/audit.ts +86 -8
- package/src/markdown/structure.ts +278 -33
- package/standards/session.md +1 -1
|
@@ -102,6 +102,8 @@ For each doc with relevant changes, apply updates following these rules. Read a
|
|
|
102
102
|
|
|
103
103
|
The verb resolves the board at the main worktree root in-process, which is the route because this is an edit inside an existing file and the file-editing tools refuse that path from a linked worktree.
|
|
104
104
|
|
|
105
|
+
Read `ok` and `reason` out of that record rather than the exit, for the reason the plans sweep below states at length. A refusal arriving as success leaves the outcome unmarked while the chain moves on, so the board reports shipped work as open and the next session re-plans it.
|
|
106
|
+
|
|
105
107
|
**REQUIREMENTS.md, ARCHITECTURE.md, DESIGN.md, `.claude/wireframes/<surface>.md`**
|
|
106
108
|
|
|
107
109
|
- Update only the sections affected by session decisions.
|
|
@@ -114,7 +114,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
|
|
|
114
114
|
- Learning that a PR moved is the mechanical half, so read `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` and start the poll it carries on the first dispatch rather than checking the board by hand. That runbook holds the routing, and a summary of it here is a second source that drifts from it.
|
|
115
115
|
7. Dispatch the handback. A pass posting a finding at any severity tells the session holding that branch to run `claude-address-review`, rather than waiting for a person to relay it. Re-review when the answer lands, then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
|
|
116
116
|
- Read the threshold off `claude-pr-review`, which states it once and governs the heading with it, so an open heading and an owed dispatch answer the same question and either one is enough to send
|
|
117
|
-
- Resolve the target
|
|
117
|
+
- Resolve the target at the moment of sending with `aitk sessions list --branch`, never from a mapping written down earlier, since names rotate as sessions end and one recorded earlier in a session has failed inside the hour. The runbook read at step 6 routes on the count and the confidence it answers with
|
|
118
118
|
- Open the message with the worktree and branch the sender believes the reader holds, asking to be corrected, whenever that mapping is inferred rather than confirmed
|
|
119
119
|
- Name the skill for the reader to run rather than writing an invocation, which arrives as text
|
|
120
120
|
- Read the pull request's own draft flag rather than the state a worker reports, since two reported a draft that read ready inside ninety seconds
|
|
@@ -80,10 +80,12 @@ The board is gitignored, so an archived task has no history behind it and nothin
|
|
|
80
80
|
Pass the task's filename stem, or the pull request number when the request names one:
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
|
-
aitk tasks archive <stem>
|
|
83
|
+
aitk tasks archive <stem> --json
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
The command refuses rather than reports,
|
|
86
|
+
The command refuses rather than reports, and the refusal reaches this skill through the record rather than through the exit. Branch on `ok`, then on `reason`. An operator's shell profile may wrap `aitk` in a function that runs the binary and then a second command and takes the second status, which masks every non-zero exit rather than only an absent verb. The binary exits 1 for an unknown subcommand and 1 for an ordinary refusal alike, so the record is the only signal that survives the wrapper.
|
|
87
|
+
|
|
88
|
+
On success the record carries `from`, `to`, `priorityRowRemoved`, and `indexRegenerated`, which is what moved, what row it cleared, and whether the index changed.
|
|
87
89
|
|
|
88
90
|
### Step 3: route on a refusal
|
|
89
91
|
|
|
@@ -27,6 +27,7 @@ Load `${CLAUDE_SKILL_DIR}/references/template.md` for the base skeleton. Copy it
|
|
|
27
27
|
- Include actionable context in error messages.
|
|
28
28
|
- Guard commands that return non-zero on a valid empty result with `|| true`.
|
|
29
29
|
- Set explicit exit codes. Reserve 0 for success.
|
|
30
|
+
- Read a called command's machine-readable record where it emits one. An exit status separates success from failure and never names which failure, so a script routing on the reason reads the record and keeps the exit for the pass-fail decision alone.
|
|
30
31
|
|
|
31
32
|
## Code style
|
|
32
33
|
|
|
@@ -53,6 +53,8 @@ Decide from what the entry says the command does, not from its name. When the en
|
|
|
53
53
|
|
|
54
54
|
If a command fails or never comes up, report the failure and the last output. Do not retry with a different command, and do not abandon the others.
|
|
55
55
|
|
|
56
|
+
An exit status here is part of the report rather than the basis for one. Step 3 confirms a service came up against what it printed, so the number is passed through to the reader and decides nothing. A documented command reaching `aitk` is the case worth naming, since an operator's shell profile may wrap the binary in a function that takes its status from a later command, and judging that run by its exit would call a refusal a success.
|
|
57
|
+
|
|
56
58
|
## Step 4: stop
|
|
57
59
|
|
|
58
60
|
Report and end the turn. Specifically do not:
|
|
@@ -88,10 +88,10 @@ aitk indexes regen --dry-run --json
|
|
|
88
88
|
Parse the JSON. On any `error` action, surface the file and reason and stop. On `would-write` for the bootstrapped folders, the dry-run is healthy. Run for real:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
aitk indexes regen
|
|
91
|
+
aitk indexes regen --json
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
Count the `written` actions in that record and report the number. Read the real run the same way the dry run is read, since the exit carries nothing reliable back to a session. An operator's shell profile may wrap `aitk` in a function that runs the binary and then a second command and takes the second status, so a regen that errored on a sibling missing `title` arrives here as a clean pass.
|
|
95
95
|
|
|
96
96
|
## Offer the convention seed
|
|
97
97
|
|
|
@@ -41,7 +41,7 @@ The measure counts fenced blocks and frontmatter, so a reference-heavy entry ran
|
|
|
41
41
|
|
|
42
42
|
Every entry past the checkpoint carries the three questions the standard asks of it rather than a count alone: whether it still covers one domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. The checkpoint is not a cap, so the count is not the finding. What a reader acts on is which question came back true, and the standard directs the fix at that rather than at the number.
|
|
43
43
|
|
|
44
|
-
Only the third is mechanical, and it is the provenance check already reported below, so the length finding cites that count rather than measuring it again. An entry outside the governed folder reads open there too, since provenance is scoped to the standard stating it and a clean list there is a scan that never ran. The other two stay open beside every entry, because whether a domain is still one domain is a judgment about its subject and recognizing reproduced content needs a reader who knows what those commands emit. Omitting them would read as an entry nothing found rather than one nothing checked.
|
|
44
|
+
Only the third is mechanical, and it is the provenance check already reported below, so the length finding cites that count rather than measuring it again. What it cites is a count of change markers, which is why an entry recording what its own runs cost answers `no` there while carrying dates throughout. An entry outside the governed folder reads open there too, since provenance is scoped to the standard stating it and a clean list there is a scan that never ran. The other two stay open beside every entry, because whether a domain is still one domain is a judgment about its subject and recognizing reproduced content needs a reader who knows what those commands emit. Omitting them would read as an entry nothing found rather than one nothing checked.
|
|
45
45
|
|
|
46
46
|
It reports and never gates, which the standard settles rather than the usual judgment-against-fact line: there is no hard cap, so an entry that answers all three and is still long is a correct outcome. The JSON record carries the joined findings as `length`, each with a `causes` array in the standard's order, and the jurisdiction the join reads as `entries[].governed`. That flag is what parts a provenance list measured and empty from one never scanned, which the count alone cannot say. The join is published rather than left to a consumer, since deriving it means restating which question the provenance count answers.
|
|
47
47
|
|
|
@@ -55,6 +55,10 @@ The table check reports a catalog that grows a row per shipped thing, not a tabl
|
|
|
55
55
|
|
|
56
56
|
The provenance check reports the markers narrating how a domain reached its shape rather than describing what it is: a date, a change number, or a release label. The standard admits a rejected alternative and the reasoning that killed it while refusing the provenance attached to it, so a marker names a line to read rather than a line to delete. Findings group by entry and sort left to right within a line, since what a reader acts on is which file to open.
|
|
57
57
|
|
|
58
|
+
A date stamping a measurement is excluded, because the standard cuts a date attached to a change and permits one dating a figure. The check reads the clause in front of the date, back to the nearest sentence boundary, for one of five verbs: measured, verified, driven, passed, and fired. The noun `run` counts only where it sits against the date, so `A run on 2026-08-14` is excluded and `Runs on #632 and #634 landed 2026-08-02` reports. The set is closed, and a date it cannot place reports as a change marker rather than as a state of its own, which names one date too many rather than clearing one the rule cuts.
|
|
59
|
+
|
|
60
|
+
A release label reports with or without its leading `v` at three segments, since the rule cuts the label rather than a spelling of it. Two segments still need the `v`, which keeps a dollar cost and a duration out. Another tool's version reports too, and the check cannot tell one from a release, so treat a version beside a tool name as a line to read rather than one to cut.
|
|
61
|
+
|
|
58
62
|
Fenced blocks are excluded, which keeps a pinned version in an install command from reading as a claim the entry makes. Frontmatter is excluded with them, since the content checks read the body alone, and that is what keeps a diagram entry's dated `verified` stamp a record of its last check rather than a marker to settle. Length is the exception, counting the whole file, so a reader applying the 150-rendered-line checkpoint against the body alone lands a few lines under what the tool reports.
|
|
59
63
|
|
|
60
64
|
## Narration
|
package/docs/agents/indexes.md
CHANGED
|
@@ -22,6 +22,8 @@ Each is driven by a `PostToolUse` hook matching `Write|Edit|MultiEdit`, so a fil
|
|
|
22
22
|
|
|
23
23
|
Exit codes: `0` clean, `1` frontmatter error or missing index, `2` drift found in `--dry-run`.
|
|
24
24
|
|
|
25
|
+
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 `results` array rather than the exit when a skill consumes this, on the write pass as well as the dry run.
|
|
26
|
+
|
|
25
27
|
When positional paths are passed inside a git repo, modified `index.md` files are staged so lint-staged and Claude `PostToolUse` hooks commit the regenerated catalog. Whole-repo walks never auto-stage, and neither does a path git ignores, since staging one always fails and the warning would fire on every edit.
|
|
26
28
|
|
|
27
29
|
Skills can parse drift without branching on exit code:
|
package/docs/agents/intake.md
CHANGED
|
@@ -60,6 +60,8 @@ An item already carrying an answer refuses rather than being overwritten, and on
|
|
|
60
60
|
|
|
61
61
|
Exit codes: `0` every named item now carries its answer, `1` refused. The `reason` field carries `no-intake`, `no-folder`, `no-cluster`, `no-item`, `answered`, or `bad-input`.
|
|
62
62
|
|
|
63
|
+
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 `reason` rather than the exit when a skill consumes this, which matters most here because the verb writes.
|
|
64
|
+
|
|
63
65
|
`bad-input` covers a malformed command line: no cluster, no selection, a selection that parses to no label and answer, an empty answer, an answer carrying a line break, or two answers for one item. It is separate from the reasons describing the folder, so a caller that mistyped a flag is not sent to repair a file that is fine.
|
|
64
66
|
|
|
65
67
|
An empty answer refuses rather than writing an empty slot. The slot means unread while it is empty, so writing one back would report an item as answered that nobody decided.
|
|
@@ -27,7 +27,9 @@ A bare run measures every markdown file git lists, tracked plus untracked-and-no
|
|
|
27
27
|
|
|
28
28
|
## Where the rules come from
|
|
29
29
|
|
|
30
|
-
The three ban sets and all
|
|
30
|
+
The three ban sets and all nine checkpoints ship with the `aitk` package as data, in `src/markdown/bans.ts` and `src/markdown/structure.ts`. Every project is measured against the same sets whether or not it installed any standards, and no file has to resolve for a run to mean something.
|
|
31
|
+
|
|
32
|
+
Six of the nine are stated in `markdown.md` and the three cadence numbers are stated in the `write-human` skill. That split is the content boundary rather than an accident: `markdown.md` carries the enforced rules a scan can decide, and the skill carries the rhythm rules a ban list cannot express. A cadence number moved in the skill and left in the code drifts the same way, so move both.
|
|
31
33
|
|
|
32
34
|
Reading them out of the standards per run was the original design. It put a parser contract on a document authored for people, and the standard had to carry a paragraph of its own warning an author that a one-word backticked example in a `- Do not use ` bullet would be lifted into a literal ban set and ban that word everywhere. A rule existing to protect a parser from the prose it parses is the argument for separating them.
|
|
33
35
|
|
|
@@ -98,9 +100,29 @@ That re-sample ran once the scan stopped counting link syntax as prose. Findings
|
|
|
98
100
|
|
|
99
101
|
A bullet, a heading, a table row, a blockquote, a blank line, and a fence each end a paragraph, so a heavy bullet is reported by the bullet check alone and never counted twice.
|
|
100
102
|
|
|
103
|
+
### Cadence
|
|
104
|
+
|
|
105
|
+
Uniform cadence is the failure a ban list cannot express. A ban set states negatives, and fragments, verbless clauses, and sentences that all run one length are each the absence of something, so no addition to the thirteen banned words reaches any of them. The shape layer already measured a bullet, a paragraph, and a run, and stopped one level above where that failure lives.
|
|
106
|
+
|
|
107
|
+
Cadence measures a paragraph on two numbers. The spread is the words between its longest and shortest sentence, and the opener count is the times one word opens a sentence in it. A spread of five words or under reads as one cadence, and a word opening more than two sentences is a pattern rather than a coincidence. Both come from `## Rhythm` in the `write-human` skill, which states them about prose a person reads, and this measures against that statement rather than setting a threshold of its own.
|
|
108
|
+
|
|
109
|
+
Words are counted off the text a reader is shown. A link contributes its anchor text, an autolink contributes nothing, and the sentence boundaries do not move under that masking, since the boundary pattern requires whitespace after the terminal punctuation and no destination carries any. An opening word is lowercased and stripped of punctuation, so a sentence opening on a backticked command name reports the command.
|
|
110
|
+
|
|
111
|
+
A paragraph carrying fewer than three sentences is skipped rather than scored. A two-sentence configuration note has no spread worth reading, and the opener rule is written about a third sentence turning a coincidence into a pattern, so neither number says anything before the floor. That is the cheap form of a wider exemption: a shape-aware one, exempting a short reference block by what it is rather than by how many sentences it holds, waits on a second case.
|
|
112
|
+
|
|
113
|
+
The unit is the paragraph and each file names its worst on each measure, which follows the depth check rather than setting a precedent. A file's flattest paragraph and its most repetitive one are named only when each crosses its checkpoint, so a file reading healthy names nothing rather than offering its least healthy paragraph as a finding.
|
|
114
|
+
|
|
115
|
+
Neither number gates and neither names a file wrong. This is a weaker claim than the one the weight checkpoints make, because a healthy range differs by surface: a catalog entry is several short sentences carrying one fact each, and a page arguing a decision is not, so one range applied across the corpus would report the surfaces that are correct. The run therefore states where the numbers came from beside them, and the counts are what a reader compares against.
|
|
116
|
+
|
|
117
|
+
Measured at `6c273324` across the corpus, 2119 paragraphs reached the floor, 165 sat at or under the spread checkpoint and 50 past the opener checkpoint. Over the 60 files carrying at least ten measured paragraphs the flat rate runs from zero to 21 percent with a median near 6, which is the spread that makes the measure worth reading. Two wiki pages sit at either end of it: a server catalog reports 19 percent, its flattest paragraph five sentences within four words of each other and three of them verbless, while a page arguing where the prose rules came from reports none.
|
|
118
|
+
|
|
119
|
+
That reading travels with the command rather than staying here. `BASELINE` in `src/markdown/structure.ts` carries the overall share, the per-file range, and the ten-paragraph floor beneath which a file's own rate says nothing, and the run prints all four in the legend beside the rate it measured. A count with no range beside it reads as a finding, and naming that a healthy range differs by surface states that a range exists rather than what it looks like. This page is toolkit-internal, so a reader running the command in a project that installed no standards would otherwise have two counts and nothing to place them against.
|
|
120
|
+
|
|
121
|
+
Two of the rules `write-human` states are deliberately not implemented. A sentence's grammatical shape and whether it carries a finite verb each need a parse rather than a match, and an imperative or a heading fragment would read as a defect under a pattern that approximated either. The verbless share is the measure closest to the reported symptom, which is exactly why shipping it wrong would discredit the two that hold. It ships when something can identify a finite verb rather than guess at one.
|
|
122
|
+
|
|
101
123
|
## Exit codes
|
|
102
124
|
|
|
103
|
-
Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling fails the run. Bullet, paragraph, and depth weight are judgments a reader settles, so all
|
|
125
|
+
Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling fails the run. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code.
|
|
104
126
|
|
|
105
127
|
`3` is separate from `1` because the two want different responses from a caller. A refusal means no corpus was built, and the `Markdown bans` stage in `scripts/core/verify.sh` is right to warn and skip. An empty set means the corpus was walked and nothing was looked for, so that stage fails the push on `3` rather than skipping.
|
|
106
128
|
|
package/docs/agents/records.md
CHANGED
|
@@ -58,6 +58,8 @@ A section runs to the next marker-shaped line whatever it names, so a plan carry
|
|
|
58
58
|
|
|
59
59
|
Exit codes: `0` every check passed, `1` refused, `2` at least one record carries a finding. A `reason` field carries which gate fired: `no-folder` when none of the kind's directories exist, and `unknown-kind` when the argument names no published kind. A `no-folder` message names every candidate, so the `standards` refusal names both roots.
|
|
60
60
|
|
|
61
|
+
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 `findings` array and its `reason` rather than the exit when a skill consumes this.
|
|
62
|
+
|
|
61
63
|
The four record folders are shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. A linked worktree validates the same records every other session reads.
|
|
62
64
|
|
|
63
65
|
`standards` takes the other default, the root of the checkout the caller stands in. The corpus is tracked, so a linked worktree holds its own edited copy, and resolving the main root there would report on a tree the session never touched while saying nothing about which one it read. A session that adds or renames a standard inside a worktree is the case, and it is the one the check exists for.
|
package/docs/agents/sessions.md
CHANGED
|
@@ -24,6 +24,8 @@ It reads and never writes. The question it answers is which session to address w
|
|
|
24
24
|
|
|
25
25
|
Exit codes: `0` the roster was read, `1` refused. The refusal carries a `reason` of `no-registry` or `no-repository`.
|
|
26
26
|
|
|
27
|
+
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 `reason` rather than the exit when a skill consumes this.
|
|
28
|
+
|
|
27
29
|
## Scope and count
|
|
28
30
|
|
|
29
31
|
`--branch` scopes the match to the repository the command runs in, and refuses outside one. A branch name identifies a branch inside a repository and nothing across a machine, so an unscoped match reaches a session working in a different project, and `main` collides on every machine running two of them.
|
|
@@ -67,7 +67,11 @@ aitk claude skills drift 02d7b265 --json
|
|
|
67
67
|
| -------- | ---------------------------------------------------------- |
|
|
68
68
|
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
69
69
|
|
|
70
|
-
A skill body enters a session once and stays. Re-invoking the skill
|
|
70
|
+
A skill body enters a session once and stays. Re-invoking the skill does not re-read the file, so an edit made this session is not picked up and nothing compares the two. A session that outlives a merge touching a body it loaded keeps applying what it loaded, and a compaction carries the held copy forward with the summary it writes, which makes that exposure the age of the oldest load rather than the age of the session.
|
|
71
|
+
|
|
72
|
+
Age is one route and not the only one. A session that edits a body and then invokes that same skill later in the same run replays the pre-edit text, which is what a ship chain does whenever a branch touches a body the chain runs at the end. This verb does not reach that route, because it reads history and the edit is still uncommitted when the replay happens. Re-read a body after editing it, and do not wait for a report to name it.
|
|
73
|
+
|
|
74
|
+
Which shape the staleness takes decides whether anyone notices. A held body naming a file the branch deleted fails loudly by having nothing to read. One naming a file that still exists while saying something different resolves and reads current, so the session applies the stale rule and reports success.
|
|
71
75
|
|
|
72
76
|
The ref is required and carries no default. `HEAD` is the only value the command could supply for itself, and it answers every run with nothing moved, which is the silence this verb exists to break. A session passes the commit it started from.
|
|
73
77
|
|
|
@@ -83,6 +87,8 @@ The report names the newest commit that rewrote each body rather than the first,
|
|
|
83
87
|
|
|
84
88
|
The report says a file moved, not that a session holds a stale copy. A session cannot read its own loaded body as bytes, so the comparison runs against history and a ref older than the oldest load over-reports. That is the safe direction, since confirming a name costs one read of the body while the failure being answered is silence. Every run states the bound, including the run that names nothing.
|
|
85
89
|
|
|
90
|
+
The verb answers only when someone runs it, and a report read is not a report acted on. A session that sees a moved body in the result and does not re-read that body stands where it started. One session reached that outcome after running the verb at session start, then followed the held copy for the rest of the day.
|
|
91
|
+
|
|
86
92
|
Two cases fall outside the range. An uncommitted edit in the working tree is not history yet, so a body changed and left unstaged reports as unmoved. A target project loads the plugin from a marketplace cache with no repository behind it, where the verb refuses and names the absent history rather than reporting a clean tree.
|
|
87
93
|
|
|
88
94
|
Exit codes are `0` when history was read, whether or not a body moved, and `1` when the question could not be answered. An absent shipped corpus, an absent repository, and a ref matching no commit each refuse with their own reason on stderr. A command exiting zero on a question it never answered is the failure `.claude/ARCHITECTURE.md` already records against two skills, which is why no path here reports an empty result in place of a refusal.
|
package/docs/operating-model.md
CHANGED
|
@@ -40,7 +40,7 @@ One feature travels this path end to end.
|
|
|
40
40
|
2. Orchestrator plans the next feature with `claude-feature`, writing a plan to `.claude/plans/`. Planning stays in the warm session because good planning is cross-feature. It needs the contract other features consume and the shared wiring seam. A cold session would re-derive or guess.
|
|
41
41
|
3. The human opens a worker worktree with `claude-worktree` and runs `claude-autoship` against the plan. The worker builds, self-checks, opens a PR, and stops at the PR boundary.
|
|
42
42
|
4. Orchestrator reviews the PR with `claude-pr-review` and posts findings to it.
|
|
43
|
-
5. Orchestrator tells the session holding that branch to run `claude-address-review` once the pass posted a finding at any severity, resolving the target
|
|
43
|
+
5. Orchestrator tells the session holding that branch to run `claude-address-review` once the pass posted a finding at any severity, resolving the target then with `aitk sessions list --branch` and reporting the invocation for the human when no live session holds it. The worker addresses the findings, rebases onto `origin/main` when a sibling landed first and left the branch unable to merge, then pushes a follow-up. A pass carrying only minor findings dispatches too, since the grade runs low often enough that a floor at should-fix loses fixes a worker would have made. `claude-pr-review` states that threshold and the heading follows it, so an open heading is itself the signal to send.
|
|
44
44
|
6. Orchestrator closes the review out with `claude-pr-review` again. The second pass reads only the commits the follow-up added, or the worker's response alone when the follow-up added none, and posts under `## Review` when it finds anything and under `## Review closed` when it finds nothing, so a reader learns from the heading whether work is still owed and takes the merge decision from the counts on the line under it. Repeat from step 5 until a pass closes the review.
|
|
45
45
|
7. The human reads the result and merges. The orchestrator tells any trailing worker whose branch shares a seam with the merged one to run `claude-address-review`, which rebases whether or not the review left anything open.
|
|
46
46
|
|
|
@@ -13,6 +13,11 @@ paths:
|
|
|
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
15
|
|
|
16
|
+
## After editing
|
|
17
|
+
|
|
18
|
+
- Re-read a skill body this session edited before invoking that skill again in the same session
|
|
19
|
+
- Do not read a resolved file path in a held body as evidence the body is current
|
|
20
|
+
|
|
16
21
|
## Authority
|
|
17
22
|
|
|
18
23
|
- Follow `.claude/standards/skill.md` for skill structure, frontmatter fields, invocation rules, and the shape a `REQUIREMENT.md` states. It is the single source.
|
package/package.json
CHANGED
package/src/commands/context.ts
CHANGED
|
@@ -503,7 +503,7 @@ function readCause(cause: LengthCause): string {
|
|
|
503
503
|
if (cause.state === 'unanswered') return `${label}: open`
|
|
504
504
|
if (cause.state === 'no') return `${label}: no`
|
|
505
505
|
|
|
506
|
-
return `${label}: yes, ${plural(cause.markers ?? 0, 'marker')}`
|
|
506
|
+
return `${label}: yes, ${plural(cause.markers ?? 0, 'change marker')}`
|
|
507
507
|
}
|
|
508
508
|
|
|
509
509
|
/**
|
|
@@ -534,7 +534,7 @@ function reportLength(over: readonly LengthFinding[]): void {
|
|
|
534
534
|
'The checkpoint is not a cap. Each entry carries the three questions the standard asks past it, and the fix goes to whichever is true.',
|
|
535
535
|
)
|
|
536
536
|
logInfo(
|
|
537
|
-
'Own history is answered from the
|
|
537
|
+
'Own history is answered from the change markers below. A date stamping a measurement is not one of them, so an entry recording what its runs cost answers no. The other two are judgments no measure settles, so they stay open for a reader.',
|
|
538
538
|
)
|
|
539
539
|
pipeOutput(
|
|
540
540
|
over
|
|
@@ -595,7 +595,9 @@ function reportProvenance(
|
|
|
595
595
|
logInfo(
|
|
596
596
|
`Covers .claude/${PROVENANCE_FOLDER}/ alone, whose standard carries the rule. The sibling standards do not restate it.`,
|
|
597
597
|
)
|
|
598
|
-
logInfo(
|
|
598
|
+
logInfo(
|
|
599
|
+
'Fenced blocks are excluded, and so is a date whose clause stamps a measurement. A marker is a judgment, never a defect.',
|
|
600
|
+
)
|
|
599
601
|
|
|
600
602
|
const carrying = entries
|
|
601
603
|
.filter((entry) => entry.provenance.length > 0)
|
package/src/commands/markdown.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
scanBans,
|
|
12
12
|
} from '@/markdown/scan'
|
|
13
13
|
import {
|
|
14
|
+
BASELINE,
|
|
14
15
|
CHECKPOINTS,
|
|
15
16
|
type Checkpoints,
|
|
16
17
|
measureStructure,
|
|
@@ -58,7 +59,7 @@ export function register(program: Command): void {
|
|
|
58
59
|
markdown
|
|
59
60
|
.command('audit')
|
|
60
61
|
.description(
|
|
61
|
-
'Fail on a banned character, word, or spelling, and report bullet, paragraph, and depth
|
|
62
|
+
'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, and depth',
|
|
62
63
|
)
|
|
63
64
|
.argument(
|
|
64
65
|
'[path...]',
|
|
@@ -80,6 +81,10 @@ export function register(program: Command): void {
|
|
|
80
81
|
'depth weight are judgments a reader settles, so all three report and',
|
|
81
82
|
'none of them fails a run.',
|
|
82
83
|
'',
|
|
84
|
+
'Cadence reports the same way and carries one more caveat. Its range is',
|
|
85
|
+
'drawn from prose a person reads, so terse reference prose sits below',
|
|
86
|
+
'it correctly and a flat paragraph there is not a defect.',
|
|
87
|
+
'',
|
|
83
88
|
'Rewrite the sentence carrying a hit rather than swapping the token for',
|
|
84
89
|
'a near-synonym. A code span clears the report and is the answer only',
|
|
85
90
|
'where the token is genuinely an identifier under discussion, which is',
|
|
@@ -146,6 +151,7 @@ async function runAudit(
|
|
|
146
151
|
reportBans(reports, bans, empty)
|
|
147
152
|
reportBullets(reports, checkpoints)
|
|
148
153
|
reportParagraphs(reports, checkpoints)
|
|
154
|
+
reportCadence(reports, checkpoints)
|
|
149
155
|
reportDepth(reports, checkpoints)
|
|
150
156
|
outro()
|
|
151
157
|
|
|
@@ -168,6 +174,9 @@ async function runAudit(
|
|
|
168
174
|
paragraph: checkpoints.paragraph,
|
|
169
175
|
sentences: checkpoints.sentences,
|
|
170
176
|
renderWidth: checkpoints.renderWidth,
|
|
177
|
+
cadence: checkpoints.cadence,
|
|
178
|
+
spread: checkpoints.spread,
|
|
179
|
+
opener: checkpoints.opener,
|
|
171
180
|
},
|
|
172
181
|
entries: reports.map((report) => ({
|
|
173
182
|
path: report.rel,
|
|
@@ -176,6 +185,7 @@ async function runAudit(
|
|
|
176
185
|
longestRunLine: report.structure.longestRunLine,
|
|
177
186
|
heavyBullets: report.structure.heavyBullets,
|
|
178
187
|
heavyParagraphs: report.structure.heavyParagraphs,
|
|
188
|
+
cadence: report.structure.cadence,
|
|
179
189
|
})),
|
|
180
190
|
})}\n`,
|
|
181
191
|
)
|
|
@@ -377,6 +387,99 @@ function reportParagraphs(
|
|
|
377
387
|
)
|
|
378
388
|
}
|
|
379
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Reports the distribution rather than a verdict, which is what separates this
|
|
392
|
+
* step from the three above it.
|
|
393
|
+
*
|
|
394
|
+
* Bullet, paragraph, and depth weight each report a count against a checkpoint
|
|
395
|
+
* a reader settles. Cadence reports a count as well, and the range behind it is
|
|
396
|
+
* drawn from one surface class rather than from the corpus, so the step states
|
|
397
|
+
* where the numbers came from beside them. A reader whose file is terse
|
|
398
|
+
* reference prose is meant to read a flat paragraph as correct, and a number
|
|
399
|
+
* printed with no comparison beside it reads as a finding whatever the step is
|
|
400
|
+
* called.
|
|
401
|
+
*/
|
|
402
|
+
function reportCadence(
|
|
403
|
+
reports: readonly FileReport[],
|
|
404
|
+
checkpoints: Checkpoints,
|
|
405
|
+
): void {
|
|
406
|
+
logStep('Cadence')
|
|
407
|
+
logInfo(
|
|
408
|
+
`Paragraphs of ${checkpoints.cadence} sentences or more measure the words between their longest and shortest sentence, and the times one word opens a sentence.`,
|
|
409
|
+
)
|
|
410
|
+
logInfo(
|
|
411
|
+
`A spread of ${checkpoints.spread} words or under reads as one cadence, and a word opening more than ${checkpoints.opener} sentences is a pattern rather than a coincidence.`,
|
|
412
|
+
)
|
|
413
|
+
logInfo(
|
|
414
|
+
'Both numbers are stated under Rhythm in the write-human skill, which writes them about prose a person reads.',
|
|
415
|
+
)
|
|
416
|
+
logInfo(
|
|
417
|
+
'A shorter paragraph stays unmeasured, since a two-sentence note carries no spread worth reading.',
|
|
418
|
+
)
|
|
419
|
+
logInfo(
|
|
420
|
+
'A healthy range differs by surface, so neither number gates and neither names a file wrong. Terse reference prose sits below the range a page written for a reader sits in.',
|
|
421
|
+
)
|
|
422
|
+
logInfo(
|
|
423
|
+
`The corpus these numbers were read against measured ${BASELINE.flatShare} percent flat overall, and its files carrying ${BASELINE.floor} or more measured paragraphs ran from ${BASELINE.low} to ${BASELINE.high} percent with a median near ${BASELINE.median}. Compare a rate against that rather than against zero.`,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
const measured = reports.reduce(
|
|
427
|
+
(sum, report) => sum + report.structure.cadence.measured,
|
|
428
|
+
0,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if (measured === 0) {
|
|
432
|
+
logInfo(
|
|
433
|
+
`No paragraph reached ${checkpoints.cadence} sentences, so nothing was measured.`,
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const flat = reports.reduce(
|
|
439
|
+
(sum, report) => sum + report.structure.cadence.flat,
|
|
440
|
+
0,
|
|
441
|
+
)
|
|
442
|
+
const repeating = reports.reduce(
|
|
443
|
+
(sum, report) => sum + report.structure.cadence.repeating,
|
|
444
|
+
0,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
logInfo(
|
|
448
|
+
`${plural(measured, 'paragraph')} measured, ${flat} at or under the spread checkpoint (${Math.round((flat / measured) * 100)} percent) and ${repeating} past the opener checkpoint.`,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
const carrying = reports
|
|
452
|
+
.filter(
|
|
453
|
+
(report) =>
|
|
454
|
+
report.structure.cadence.flattest ||
|
|
455
|
+
report.structure.cadence.mostRepeated,
|
|
456
|
+
)
|
|
457
|
+
.sort(
|
|
458
|
+
(a, b) =>
|
|
459
|
+
b.structure.cadence.flat +
|
|
460
|
+
b.structure.cadence.repeating -
|
|
461
|
+
(a.structure.cadence.flat + a.structure.cadence.repeating),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
if (carrying.length === 0) return
|
|
465
|
+
|
|
466
|
+
pipeOutput(
|
|
467
|
+
carrying
|
|
468
|
+
.map((report) => {
|
|
469
|
+
const { flattest, mostRepeated } = report.structure.cadence
|
|
470
|
+
const lines = [
|
|
471
|
+
flattest &&
|
|
472
|
+
` :${flattest.line} ${plural(flattest.sentences, 'sentence')}, spread ${plural(flattest.spread, 'word')}`,
|
|
473
|
+
mostRepeated &&
|
|
474
|
+
` :${mostRepeated.line} ${plural(mostRepeated.sentences, 'sentence')}, "${mostRepeated.opener}" opens ${mostRepeated.repeats}`,
|
|
475
|
+
].filter(Boolean)
|
|
476
|
+
|
|
477
|
+
return `${report.rel}\n${lines.join('\n')}`
|
|
478
|
+
})
|
|
479
|
+
.join('\n'),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
380
483
|
/**
|
|
381
484
|
* Names the render width and the blank-line convention on every run.
|
|
382
485
|
*
|
package/src/context/audit.ts
CHANGED
|
@@ -60,13 +60,59 @@ const INSIDE_LIST = /^\s+\S/
|
|
|
60
60
|
* when it records the second: when a change landed, which change carried it,
|
|
61
61
|
* and which release labelled it. A marker is a judgment rather than a defect,
|
|
62
62
|
* so this is measured and reported and never gates.
|
|
63
|
+
*
|
|
64
|
+
* The release pattern accepts three segments without a leading `v`, since the
|
|
65
|
+
* standard cuts a release label rather than a spelling of one and `a CLI at
|
|
66
|
+
* 0.83.0` names a release exactly as `v0.83.0` does. Two segments still require
|
|
67
|
+
* the `v`, because an unprefixed pair is a dollar cost or a duration far more
|
|
68
|
+
* often than a release in a corpus that records what its own runs cost. What
|
|
69
|
+
* the widening reaches by accident is another tool's version, which the
|
|
70
|
+
* standard asks nobody to cut, and those report rather than being excluded:
|
|
71
|
+
* an exclusion keyed on a nearby tool name goes stale with nothing saying so.
|
|
63
72
|
*/
|
|
64
73
|
const PROVENANCE: readonly { kind: ProvenanceKind; pattern: RegExp }[] = [
|
|
65
74
|
{ kind: 'date', pattern: /\b\d{4}-\d{2}-\d{2}\b/g },
|
|
66
75
|
{ kind: 'change', pattern: /#\d{3,}\b/g },
|
|
67
|
-
{ kind: 'release', pattern: /\
|
|
76
|
+
{ kind: 'release', pattern: /\b(?:v\d+\.\d+(?:\.\d+)?|\d+\.\d+\.\d+)\b/g },
|
|
68
77
|
]
|
|
69
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Verbs that read the date behind them as stamping a measurement.
|
|
81
|
+
*
|
|
82
|
+
* `standards/context.md` cuts a date attached to a change and permits one
|
|
83
|
+
* stamping a measurement, so a pattern matching every ISO date reports against
|
|
84
|
+
* a rule that admits half of what it finds. Separating the two needs the clause
|
|
85
|
+
* rather than the token, since `measured on 2026-08-19` and `overturned on
|
|
86
|
+
* 2026-08-19` differ only in the verb.
|
|
87
|
+
*
|
|
88
|
+
* These five are unambiguous wherever they appear in the clause, which is what
|
|
89
|
+
* lets them match at any distance from the date. `Measured across the corpus on
|
|
90
|
+
* 2026-08-14` puts four words between the two, and no line in the corpus uses
|
|
91
|
+
* any of them as a noun a change date could attach to.
|
|
92
|
+
*
|
|
93
|
+
* The set is closed and drawn from what the corpus already writes, so a
|
|
94
|
+
* phrasing nobody enumerated goes unmatched. That direction is the safe one: an
|
|
95
|
+
* unmatched date falls back to the change marker it is today, which reports one
|
|
96
|
+
* date too many rather than clearing one the standard cuts. Widening the set is
|
|
97
|
+
* how a missed phrasing is fixed, and a date the clause cannot place is never a
|
|
98
|
+
* third state, because the length finding already spends `unanswered` on the
|
|
99
|
+
* two questions nothing measures at all.
|
|
100
|
+
*/
|
|
101
|
+
const STAMPING = /\b(?:measured|verified|driven|passed|fired)\b/i
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The noun form, which has to sit against the date to count.
|
|
105
|
+
*
|
|
106
|
+
* `A run on 2026-08-14` stamps a measurement and `Runs on #632 and #634 landed
|
|
107
|
+
* 2026-08-02` dates a change, and the two differ only in what follows the noun.
|
|
108
|
+
* Requiring the connector and then the date immediately is what separates them,
|
|
109
|
+
* so this is anchored to the end of the clause where `STAMPING` is not.
|
|
110
|
+
*/
|
|
111
|
+
const STAMPING_RUN = /\b(?:an?\s+)?runs?\s+(?:on|at|in)\s+$/i
|
|
112
|
+
|
|
113
|
+
/** Sentence boundary, so a clause is read rather than the whole line. */
|
|
114
|
+
const SENTENCE_END = /[.!?:;]\s(?=[^.!?:;]*$)/
|
|
115
|
+
|
|
70
116
|
/**
|
|
71
117
|
* The folder whose standard carries the exclusion above.
|
|
72
118
|
*
|
|
@@ -200,9 +246,11 @@ export interface LengthCause {
|
|
|
200
246
|
readonly question: LengthQuestion
|
|
201
247
|
readonly state: QuestionState
|
|
202
248
|
/**
|
|
203
|
-
*
|
|
204
|
-
* the provenance finding rather than restating it, so the two sections
|
|
205
|
-
* report describe the same measurement once.
|
|
249
|
+
* Change markers behind a `yes`, and absent wherever nothing was counted. It
|
|
250
|
+
* cites the provenance finding rather than restating it, so the two sections
|
|
251
|
+
* of the report describe the same measurement once. A date stamping a
|
|
252
|
+
* measurement never reaches that list, so an entry whose only dates are
|
|
253
|
+
* measurement anchors answers `no` here.
|
|
206
254
|
*/
|
|
207
255
|
readonly markers?: number
|
|
208
256
|
}
|
|
@@ -284,6 +332,12 @@ function catalogTables(entry: readonly BodyLine[]): TableFinding[] {
|
|
|
284
332
|
* sample command or a fixture inside an example is content the entry displays
|
|
285
333
|
* rather than a claim it makes, and a version pinned in an install line is the
|
|
286
334
|
* ordinary shape of one.
|
|
335
|
+
*
|
|
336
|
+
* A date stamping a measurement is dropped rather than reported under a kind of
|
|
337
|
+
* its own. One list with one meaning is what lets every consumer read it
|
|
338
|
+
* without filtering: the report names what the standard cuts, and the length
|
|
339
|
+
* finding counts the same thing. A separate kind would put the split in three
|
|
340
|
+
* places and leave each free to read it differently.
|
|
287
341
|
*/
|
|
288
342
|
function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
|
|
289
343
|
// Scanning one pattern at a time emits a line's markers grouped by kind, so
|
|
@@ -297,6 +351,10 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
|
|
|
297
351
|
|
|
298
352
|
for (const { kind, pattern } of PROVENANCE) {
|
|
299
353
|
for (const match of line.text.matchAll(pattern)) {
|
|
354
|
+
if (kind === 'date' && stampsMeasurement(line.text, match.index)) {
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
|
|
300
358
|
found.push({
|
|
301
359
|
finding: { line: line.number, kind, text: match[0] },
|
|
302
360
|
column: match.index,
|
|
@@ -310,6 +368,23 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
|
|
|
310
368
|
.map((each) => each.finding)
|
|
311
369
|
}
|
|
312
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Reads the clause in front of a date for a verb that stamps a measurement.
|
|
373
|
+
*
|
|
374
|
+
* The clause rather than the line, because a sentence recording a measurement
|
|
375
|
+
* and a later one dating a change sit side by side often enough that a
|
|
376
|
+
* line-wide read would clear the second from the first. A date opening its own
|
|
377
|
+
* line has no clause in front of it and stays a change marker, which is the
|
|
378
|
+
* fallback rather than a separate answer.
|
|
379
|
+
*/
|
|
380
|
+
function stampsMeasurement(text: string, index: number): boolean {
|
|
381
|
+
const before = text.slice(0, index)
|
|
382
|
+
const boundary = before.search(SENTENCE_END)
|
|
383
|
+
const clause = boundary === -1 ? before : before.slice(boundary + 1)
|
|
384
|
+
|
|
385
|
+
return STAMPING.test(clause) || STAMPING_RUN.test(clause)
|
|
386
|
+
}
|
|
387
|
+
|
|
313
388
|
function escape(term: string): string {
|
|
314
389
|
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
315
390
|
}
|
|
@@ -536,10 +611,13 @@ export function measureEntry(
|
|
|
536
611
|
* answered as far as anything measures them.
|
|
537
612
|
*
|
|
538
613
|
* Only accumulated history is mechanical, and it is already measured by the
|
|
539
|
-
* provenance check, so this joins that count rather than counting again.
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
614
|
+
* provenance check, so this joins that count rather than counting again. What
|
|
615
|
+
* it joins is a count of change markers, since a date stamping a measurement is
|
|
616
|
+
* not one and an entry recording what its runs cost would otherwise report
|
|
617
|
+
* accumulated history on the anchors dating those runs. The other two are read
|
|
618
|
+
* by a person: whether an entry still covers one domain is a judgment about its
|
|
619
|
+
* subject, and recognizing content `ls` or `--help` reproduces needs a reader
|
|
620
|
+
* who knows what those emit.
|
|
543
621
|
*
|
|
544
622
|
* An entry outside the governed folder has no measured question at all, since
|
|
545
623
|
* provenance is scoped to the standard stating it, and reporting `no` there
|
|
@@ -33,6 +33,14 @@ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
|
|
|
33
33
|
* degraded a number and the report had to carry a legend saying which one. The
|
|
34
34
|
* standard still states every number for a reader, and moving one is an edit to
|
|
35
35
|
* both.
|
|
36
|
+
*
|
|
37
|
+
* The last three come from the `## Rhythm` section of the `write-human` skill
|
|
38
|
+
* rather than from a standard. That skill states what good rhythm is and this
|
|
39
|
+
* measures against the statement, so the numbers are read off the sentences
|
|
40
|
+
* that already carry them: a longest and shortest sentence within roughly five
|
|
41
|
+
* words of each other is one cadence, an opening word repeating twice is
|
|
42
|
+
* coincidence and three times is a pattern, and both rules are written about a
|
|
43
|
+
* paragraph, which needs a third sentence before either says anything.
|
|
36
44
|
*/
|
|
37
45
|
export const CHECKPOINTS = {
|
|
38
46
|
run: 40,
|
|
@@ -41,6 +49,37 @@ export const CHECKPOINTS = {
|
|
|
41
49
|
paragraph: 700,
|
|
42
50
|
sentences: 4,
|
|
43
51
|
renderWidth: 80,
|
|
52
|
+
cadence: 3,
|
|
53
|
+
spread: 5,
|
|
54
|
+
opener: 2,
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The reading a cadence rate is compared against, rather than a checkpoint.
|
|
59
|
+
*
|
|
60
|
+
* A count with nothing beside it reads as a finding, and the two counts the
|
|
61
|
+
* cadence step reports have no range a reader can place them in: twelve flat
|
|
62
|
+
* paragraphs out of forty says the same as two without one. Naming that a
|
|
63
|
+
* healthy range differs by surface states that a range exists rather than what
|
|
64
|
+
* it looks like, so the reading travels with the command.
|
|
65
|
+
*
|
|
66
|
+
* This is an observation rather than a rule, which is why it sits apart from
|
|
67
|
+
* `CHECKPOINTS`. Nothing compares a run against it and no exit code reads it. A
|
|
68
|
+
* project whose corpus is entirely terse reference prose is expected to sit
|
|
69
|
+
* above the high end, and that is the measure working.
|
|
70
|
+
*
|
|
71
|
+
* Read across 483 markdown files at `6c273324` on 2026-08-20. A number here
|
|
72
|
+
* goes stale against the corpus it describes with nothing comparing the two, so
|
|
73
|
+
* re-measure before moving one.
|
|
74
|
+
*/
|
|
75
|
+
export const BASELINE = {
|
|
76
|
+
/** Share of every measured paragraph sitting at or under the spread checkpoint. */
|
|
77
|
+
flatShare: 8,
|
|
78
|
+
/** Measured paragraphs a file needs before its own rate means anything. */
|
|
79
|
+
floor: 10,
|
|
80
|
+
low: 0,
|
|
81
|
+
median: 6,
|
|
82
|
+
high: 21,
|
|
44
83
|
} as const
|
|
45
84
|
|
|
46
85
|
/**
|
|
@@ -58,6 +97,12 @@ export interface Checkpoints {
|
|
|
58
97
|
readonly paragraph: number
|
|
59
98
|
readonly sentences: number
|
|
60
99
|
readonly renderWidth: number
|
|
100
|
+
/** Sentences a paragraph needs before its cadence is measured at all. */
|
|
101
|
+
readonly cadence: number
|
|
102
|
+
/** Words between the longest and shortest sentence, at or under which the paragraph reads as one cadence. */
|
|
103
|
+
readonly spread: number
|
|
104
|
+
/** Times one opening word may open a sentence in a paragraph before it is a pattern. */
|
|
105
|
+
readonly opener: number
|
|
61
106
|
}
|
|
62
107
|
|
|
63
108
|
export interface BulletFinding {
|
|
@@ -72,6 +117,47 @@ export interface ParagraphFinding {
|
|
|
72
117
|
readonly characters: number
|
|
73
118
|
}
|
|
74
119
|
|
|
120
|
+
/**
|
|
121
|
+
* One measured paragraph, carrying both cadence numbers rather than one.
|
|
122
|
+
*
|
|
123
|
+
* A paragraph reported for a narrow spread is usually worth reading for its
|
|
124
|
+
* openers too, and splitting the two into separate finding types would name the
|
|
125
|
+
* same line twice with half the picture on each.
|
|
126
|
+
*/
|
|
127
|
+
export interface CadenceFinding {
|
|
128
|
+
readonly line: number
|
|
129
|
+
readonly sentences: number
|
|
130
|
+
/** Words between the longest and shortest sentence. */
|
|
131
|
+
readonly spread: number
|
|
132
|
+
/** Times the most repeated opening word opens a sentence here. */
|
|
133
|
+
readonly repeats: number
|
|
134
|
+
/** The opening word behind `repeats`, lowercased and stripped of punctuation. */
|
|
135
|
+
readonly opener: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A file's cadence distribution, reported rather than listed finding by finding.
|
|
140
|
+
*
|
|
141
|
+
* Both numbers are advisory in a way even the weight checkpoints are not. A
|
|
142
|
+
* healthy range differs by surface, so terse reference prose and a page written
|
|
143
|
+
* for a reader sit at different spreads and one range applied across the corpus
|
|
144
|
+
* would report the surfaces that are correct. The counts are what a reader
|
|
145
|
+
* compares against the stated range, and the two worst paragraphs are where
|
|
146
|
+
* they open the file.
|
|
147
|
+
*/
|
|
148
|
+
export interface CadenceReport {
|
|
149
|
+
/** Paragraphs carrying at least the floor sentence count. */
|
|
150
|
+
readonly measured: number
|
|
151
|
+
/** Measured paragraphs at or under the spread checkpoint. */
|
|
152
|
+
readonly flat: number
|
|
153
|
+
/** Measured paragraphs past the opener checkpoint. */
|
|
154
|
+
readonly repeating: number
|
|
155
|
+
/** Narrowest paragraph among the flat ones, or undefined when none is flat. */
|
|
156
|
+
readonly flattest: CadenceFinding | undefined
|
|
157
|
+
/** Most repetitive paragraph among the repeating ones, or undefined when none repeats. */
|
|
158
|
+
readonly mostRepeated: CadenceFinding | undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
75
161
|
export interface StructureReport {
|
|
76
162
|
readonly rel: string
|
|
77
163
|
/** Rendered lines at the render width, not source lines. */
|
|
@@ -80,6 +166,7 @@ export interface StructureReport {
|
|
|
80
166
|
readonly longestRunLine: number
|
|
81
167
|
readonly heavyBullets: readonly BulletFinding[]
|
|
82
168
|
readonly heavyParagraphs: readonly ParagraphFinding[]
|
|
169
|
+
readonly cadence: CadenceReport
|
|
83
170
|
}
|
|
84
171
|
|
|
85
172
|
/**
|
|
@@ -275,48 +362,46 @@ export function heavyBullets(
|
|
|
275
362
|
return findings
|
|
276
363
|
}
|
|
277
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Splits a paragraph into the sentences terminal punctuation closes.
|
|
367
|
+
*
|
|
368
|
+
* A trailing span no punctuation closes is dropped rather than returned, which
|
|
369
|
+
* keeps this and the count below one definition. The pattern already matches a
|
|
370
|
+
* paragraph's final period through its end-of-text alternative, so the only
|
|
371
|
+
* span this drops is a paragraph genuinely ending without terminal punctuation,
|
|
372
|
+
* which is a fragment rather than a sentence to either measure.
|
|
373
|
+
*/
|
|
374
|
+
function splitSentences(text: string): string[] {
|
|
375
|
+
const sentences: string[] = []
|
|
376
|
+
let start = 0
|
|
377
|
+
|
|
378
|
+
for (const match of text.matchAll(SENTENCE_END)) {
|
|
379
|
+
const end = match.index + match[0].length
|
|
380
|
+
sentences.push(text.slice(start, end).trim())
|
|
381
|
+
start = end
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return sentences
|
|
385
|
+
}
|
|
386
|
+
|
|
278
387
|
function countSentences(text: string): number {
|
|
279
|
-
return
|
|
388
|
+
return splitSentences(text).length
|
|
280
389
|
}
|
|
281
390
|
|
|
282
391
|
/**
|
|
283
|
-
*
|
|
392
|
+
* Splits the body into its prose paragraphs, each a run of consecutive lines.
|
|
284
393
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* the sentence cap would find no weight rule to read at all.
|
|
290
|
-
*
|
|
291
|
-
* The two numbers coincide today because paragraph and bullet weight measure
|
|
292
|
-
* one population, sharing a median near 170 characters with no gap behind
|
|
293
|
-
* either candidate. They are separate checkpoints regardless, so either moves
|
|
294
|
-
* without dragging the other.
|
|
295
|
-
*
|
|
296
|
-
* A paragraph is a run of consecutive prose lines. A heading, a list item, a
|
|
297
|
-
* table row, a blockquote, a blank line, and a fence each end one, so a bullet
|
|
298
|
-
* is measured by `heavyBullets` alone and never twice.
|
|
394
|
+
* A heading, a list item, a table row, a blockquote, a blank line, and a fence
|
|
395
|
+
* each end one, so a bullet is measured by `heavyBullets` alone and never
|
|
396
|
+
* twice. Both paragraph measures walk this rather than one each, since two
|
|
397
|
+
* walks deciding what a paragraph is would drift apart.
|
|
299
398
|
*/
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
checkpoints: Checkpoints,
|
|
303
|
-
): ParagraphFinding[] {
|
|
304
|
-
const findings: ParagraphFinding[] = []
|
|
399
|
+
function paragraphBlocks(lines: readonly BodyLine[]): BodyLine[][] {
|
|
400
|
+
const blocks: BodyLine[][] = []
|
|
305
401
|
let block: BodyLine[] = []
|
|
306
402
|
|
|
307
403
|
const close = (): void => {
|
|
308
|
-
if (block.length > 0)
|
|
309
|
-
const text = block.map((line) => line.text.trim()).join(' ')
|
|
310
|
-
const sentences = countSentences(text)
|
|
311
|
-
const characters = visibleText(text).length
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
sentences > checkpoints.sentences ||
|
|
315
|
-
characters > checkpoints.paragraph
|
|
316
|
-
) {
|
|
317
|
-
findings.push({ line: block[0].number, sentences, characters })
|
|
318
|
-
}
|
|
319
|
-
}
|
|
404
|
+
if (block.length > 0) blocks.push(block)
|
|
320
405
|
block = []
|
|
321
406
|
}
|
|
322
407
|
|
|
@@ -340,9 +425,168 @@ export function heavyParagraphs(
|
|
|
340
425
|
|
|
341
426
|
close()
|
|
342
427
|
|
|
428
|
+
return blocks
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Folds a paragraph's source lines into the one line they would have wrapped from. */
|
|
432
|
+
function paragraphText(block: readonly BodyLine[]): string {
|
|
433
|
+
return block.map((line) => line.text.trim()).join(' ')
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Finds the prose paragraphs past either half of the standard's checkpoint.
|
|
438
|
+
*
|
|
439
|
+
* Both halves are stated in the standard and both are read from it. The weight
|
|
440
|
+
* half was added there rather than borrowed from the bullet checkpoint, which
|
|
441
|
+
* governs a different construct: one number feeding both would move the
|
|
442
|
+
* paragraph rule whenever the bullet rule was changed, and an author reading
|
|
443
|
+
* the sentence cap would find no weight rule to read at all.
|
|
444
|
+
*
|
|
445
|
+
* The two numbers coincide today because paragraph and bullet weight measure
|
|
446
|
+
* one population, sharing a median near 170 characters with no gap behind
|
|
447
|
+
* either candidate. They are separate checkpoints regardless, so either moves
|
|
448
|
+
* without dragging the other.
|
|
449
|
+
*/
|
|
450
|
+
export function heavyParagraphs(
|
|
451
|
+
lines: readonly BodyLine[],
|
|
452
|
+
checkpoints: Checkpoints,
|
|
453
|
+
): ParagraphFinding[] {
|
|
454
|
+
const findings: ParagraphFinding[] = []
|
|
455
|
+
|
|
456
|
+
for (const block of paragraphBlocks(lines)) {
|
|
457
|
+
const text = paragraphText(block)
|
|
458
|
+
const sentences = countSentences(text)
|
|
459
|
+
const characters = visibleText(text).length
|
|
460
|
+
|
|
461
|
+
if (
|
|
462
|
+
sentences > checkpoints.sentences ||
|
|
463
|
+
characters > checkpoints.paragraph
|
|
464
|
+
) {
|
|
465
|
+
findings.push({ line: block[0].number, sentences, characters })
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
343
469
|
return findings
|
|
344
470
|
}
|
|
345
471
|
|
|
472
|
+
/** Punctuation either side of a word, so an opener is compared on its letters. */
|
|
473
|
+
const WORD_EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The word a sentence opens on, lowercased and stripped of punctuation.
|
|
477
|
+
*
|
|
478
|
+
* A sentence opening on a code span yields the command inside it rather than a
|
|
479
|
+
* backtick, which is the word a reader hears. Casing is dropped because an
|
|
480
|
+
* opener repeating is a repetition whether or not one of the two sits mid-list
|
|
481
|
+
* and lost its capital.
|
|
482
|
+
*/
|
|
483
|
+
function openingWord(sentence: string): string {
|
|
484
|
+
const [first = ''] = sentence.split(/\s+/)
|
|
485
|
+
return first.replace(WORD_EDGE, '').toLowerCase()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Measures one paragraph's spread and its most repeated opener.
|
|
490
|
+
*
|
|
491
|
+
* Words are counted off the text a reader is shown, so a link contributes its
|
|
492
|
+
* anchor text and a destination contributes nothing. The sentence boundaries do
|
|
493
|
+
* not move with that masking, since the pattern requires whitespace after the
|
|
494
|
+
* terminal punctuation and no destination or autolink carries any.
|
|
495
|
+
*/
|
|
496
|
+
function measureParagraph(block: readonly BodyLine[]): CadenceFinding {
|
|
497
|
+
const sentences = splitSentences(visibleText(paragraphText(block)))
|
|
498
|
+
const lengths = sentences.map(
|
|
499
|
+
(sentence) => sentence.split(/\s+/).filter(Boolean).length,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
const counts = new Map<string, number>()
|
|
503
|
+
for (const sentence of sentences) {
|
|
504
|
+
const word = openingWord(sentence)
|
|
505
|
+
if (word !== '') counts.set(word, (counts.get(word) ?? 0) + 1)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let opener = ''
|
|
509
|
+
let repeats = 0
|
|
510
|
+
for (const [word, count] of counts) {
|
|
511
|
+
if (count > repeats) {
|
|
512
|
+
opener = word
|
|
513
|
+
repeats = count
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// A paragraph closing no sentence at all has no spread rather than a
|
|
518
|
+
// negative one, and the floor drops it before either number is read.
|
|
519
|
+
const spread =
|
|
520
|
+
lengths.length === 0 ? 0 : Math.max(...lengths) - Math.min(...lengths)
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
line: block[0].number,
|
|
524
|
+
sentences: sentences.length,
|
|
525
|
+
spread,
|
|
526
|
+
repeats,
|
|
527
|
+
opener,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Reports how a file's prose is distributed across sentence length and opening
|
|
533
|
+
* word, which is the layer the ban sets cannot reach.
|
|
534
|
+
*
|
|
535
|
+
* A banned-word list expresses negatives and every symptom this answers is the
|
|
536
|
+
* absence of something, so no addition to that set catches a paragraph whose
|
|
537
|
+
* sentences are all one length. The measure stops at what is countable. A
|
|
538
|
+
* sentence's grammatical shape and whether it carries a finite verb are the two
|
|
539
|
+
* rules `write-human` states that this does not implement, because identifying
|
|
540
|
+
* either needs a parse rather than a match, and an imperative or a heading
|
|
541
|
+
* fragment would read as a defect. Reporting them wrong is worse than not
|
|
542
|
+
* reporting them, since they name the exact failure this exists to measure.
|
|
543
|
+
*
|
|
544
|
+
* A paragraph under the floor is skipped rather than scored. A two-sentence
|
|
545
|
+
* configuration note has no spread worth reading, and the opener rule is
|
|
546
|
+
* written about a third sentence turning a coincidence into a pattern, so
|
|
547
|
+
* neither measure says anything before the floor is reached.
|
|
548
|
+
*/
|
|
549
|
+
export function measureCadence(
|
|
550
|
+
lines: readonly BodyLine[],
|
|
551
|
+
checkpoints: Checkpoints,
|
|
552
|
+
): CadenceReport {
|
|
553
|
+
const measured = paragraphBlocks(lines)
|
|
554
|
+
.map(measureParagraph)
|
|
555
|
+
.filter((finding) => finding.sentences >= checkpoints.cadence)
|
|
556
|
+
|
|
557
|
+
const flat = measured.filter(
|
|
558
|
+
(finding) => finding.spread <= checkpoints.spread,
|
|
559
|
+
)
|
|
560
|
+
const repeating = measured.filter(
|
|
561
|
+
(finding) => finding.repeats > checkpoints.opener,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
measured: measured.length,
|
|
566
|
+
flat: flat.length,
|
|
567
|
+
repeating: repeating.length,
|
|
568
|
+
flattest: worst(flat, (finding) => -finding.spread),
|
|
569
|
+
mostRepeated: worst(repeating, (finding) => finding.repeats),
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The file's worst paragraph on one measure, earliest line breaking a tie.
|
|
575
|
+
*
|
|
576
|
+
* Only paragraphs already past their checkpoint are passed in, so a file
|
|
577
|
+
* reading healthy names nothing rather than naming its least healthy paragraph,
|
|
578
|
+
* which a reader would take for a finding.
|
|
579
|
+
*/
|
|
580
|
+
function worst(
|
|
581
|
+
findings: readonly CadenceFinding[],
|
|
582
|
+
rank: (finding: CadenceFinding) => number,
|
|
583
|
+
): CadenceFinding | undefined {
|
|
584
|
+
return findings.reduce<CadenceFinding | undefined>(
|
|
585
|
+
(held, finding) => (!held || rank(finding) > rank(held) ? finding : held),
|
|
586
|
+
undefined,
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
346
590
|
export function measureStructure(
|
|
347
591
|
rel: string,
|
|
348
592
|
lines: readonly BodyLine[],
|
|
@@ -356,5 +600,6 @@ export function measureStructure(
|
|
|
356
600
|
longestRunLine: run.line,
|
|
357
601
|
heavyBullets: heavyBullets(lines, checkpoints),
|
|
358
602
|
heavyParagraphs: heavyParagraphs(lines, checkpoints),
|
|
603
|
+
cadence: measureCadence(lines, checkpoints),
|
|
359
604
|
}
|
|
360
605
|
}
|
package/standards/session.md
CHANGED
|
@@ -57,7 +57,7 @@ Add a section only for content that fits none of the three and would otherwise b
|
|
|
57
57
|
## Writing one
|
|
58
58
|
|
|
59
59
|
1. Capture what the session learned first, so the map cites what was written instead of restating the same lesson in prose.
|
|
60
|
-
2. Run `aitk claude skills drift <the commit this session started from>` and record what it names under `## Standing cautions`. A skill body enters a session once and re-invoking the skill
|
|
60
|
+
2. Run `aitk claude skills drift <the commit this session started from>` and record what it names under `## Standing cautions`. A skill body enters a session once and re-invoking the skill does not re-read the file, so the drift is worst at exactly this moment and a name here is a body the session has been following out of date. Recording a name is not acting on it, and the verb misses a body this session edited and has not committed. This step belongs to session length rather than to any role, so every writer runs it, and a refusal names the boundary of what the verb can read rather than a fault.
|
|
61
61
|
3. Recover that commit from how long the session has been running with `git log -1 --format=%H --before='<duration> ago'`, rounding the duration up rather than down. Nothing on the machine records it. A ref older than the oldest load over-reports, and confirming a name costs one read of the body, so the generous end is the safe one and a guess at the exact commit is not worth making.
|
|
62
62
|
4. Write only what a compaction destroys and no other artifact already carries. The board holds the ordering and what each task waits on, a task file holds its own findings, and a measurement folder holds its track.
|
|
63
63
|
5. Cite a commit, a task, or a file and line for every claim, so the next session can tell a read from a recall.
|