@erclx/aitk 0.63.1 → 0.64.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.
Files changed (39) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-docs/SKILL.md +3 -1
  3. package/claude/skills/claude-feature/SKILL.md +2 -0
  4. package/claude/skills/claude-memory-capture/SKILL.md +9 -2
  5. package/claude/skills/claude-memory-review/SKILL.md +5 -2
  6. package/claude/skills/claude-review/SKILL.md +2 -0
  7. package/claude/skills/claude-screencast/SKILL.md +2 -0
  8. package/claude/skills/claude-seed-sync/SKILL.md +2 -0
  9. package/claude/skills/claude-tasks/SKILL.md +3 -1
  10. package/claude/skills/claude-ui-test/SKILL.md +2 -0
  11. package/claude/skills/claude-ux-audit/SKILL.md +2 -0
  12. package/claude/skills/git-pr/SKILL.md +9 -3
  13. package/docs/agents/commands.md +5 -1
  14. package/docs/agents/context-audit-checks.md +9 -11
  15. package/docs/agents/context-audit.md +3 -1
  16. package/docs/agents/index.md +3 -2
  17. package/docs/agents/markdown-audit.md +70 -0
  18. package/docs/agents/tasks.md +51 -2
  19. package/docs/ai-workflow.md +1 -1
  20. package/package.json +1 -1
  21. package/src/cli.ts +4 -0
  22. package/src/commands/context.ts +8 -115
  23. package/src/commands/markdown.ts +383 -0
  24. package/src/commands/records.ts +1 -18
  25. package/src/commands/tasks.ts +314 -19
  26. package/src/context/audit.ts +34 -309
  27. package/src/context/citations.ts +2 -30
  28. package/src/git-files.ts +31 -0
  29. package/src/markdown/bans.ts +241 -0
  30. package/src/markdown/files.ts +92 -0
  31. package/src/markdown/scan.ts +183 -0
  32. package/src/markdown/structure.ts +408 -0
  33. package/src/records/validate.ts +1 -37
  34. package/src/tasks/archive.ts +34 -3
  35. package/src/tasks/record.ts +311 -0
  36. package/src/worktree.ts +23 -0
  37. package/standards/context.md +2 -7
  38. package/standards/markdown.md +10 -2
  39. package/tooling/claude/seeds/CLAUDE.md +1 -0
@@ -0,0 +1,311 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { basename, join, relative } from 'node:path'
4
+ import {
5
+ fenceMask,
6
+ listTaskStems,
7
+ OUTCOME_PATTERN,
8
+ readPlanTarget,
9
+ tasksDir,
10
+ } from '@/tasks/archive'
11
+
12
+ /**
13
+ * Lines a task carries above its outcomes naming where the work came from. The
14
+ * `Pull request:` line joins them, so the last one present is the anchor.
15
+ */
16
+ const ORIGIN_PREFIXES = ['Plan:', 'Groundwork:', 'Intake:', 'Issue:'] as const
17
+
18
+ /**
19
+ * `bad-input` is separated from the board-state reasons on purpose. `git-pr`
20
+ * swallows `no-board`, `no-match`, and `ambiguous`, because each is a case where
21
+ * a guessed write would archive the wrong task. A caller that passed a
22
+ * malformed argument has a defect nobody would otherwise hear about, so it
23
+ * reports rather than joining the swallowed set.
24
+ */
25
+ export const RECORD_REFUSALS = [
26
+ 'no-board',
27
+ 'no-match',
28
+ 'ambiguous',
29
+ 'no-outcomes',
30
+ 'out-of-range',
31
+ 'bad-input',
32
+ ] as const
33
+
34
+ export type RecordRefusal = (typeof RECORD_REFUSALS)[number]
35
+
36
+ export interface RecordRefused {
37
+ readonly ok: false
38
+ readonly reason: RecordRefusal
39
+ readonly message: string
40
+ readonly detail: readonly string[]
41
+ }
42
+
43
+ export type RecordSelector =
44
+ | { readonly kind: 'stem'; readonly stem: string }
45
+ | { readonly kind: 'plan'; readonly plan: string }
46
+
47
+ export type LineAction = 'added' | 'corrected' | 'unchanged'
48
+
49
+ export interface PullRequestRecorded {
50
+ readonly ok: true
51
+ readonly stem: string
52
+ readonly path: string
53
+ readonly number: number
54
+ readonly action: LineAction
55
+ }
56
+
57
+ export type PullRequestOutcome = PullRequestRecorded | RecordRefused
58
+
59
+ export interface OutcomesClosed {
60
+ readonly ok: true
61
+ readonly stem: string
62
+ readonly path: string
63
+ readonly closed: readonly string[]
64
+ readonly alreadyClosed: readonly string[]
65
+ }
66
+
67
+ export type CloseOutcome = OutcomesClosed | RecordRefused
68
+
69
+ function refuse(
70
+ reason: RecordRefusal,
71
+ message: string,
72
+ detail: readonly string[] = [],
73
+ ): RecordRefused {
74
+ return { ok: false, reason, message, detail }
75
+ }
76
+
77
+ /**
78
+ * Reduces a plan reference to the token both spellings share. A task's `Plan:`
79
+ * line carries a path, a caller carries the branch slug, and the two differ by
80
+ * the folder, the extension, and the `feature-` prefix the filename adds.
81
+ */
82
+ function planKey(reference: string): string {
83
+ const stem = basename(reference).replace(/\.md$/, '')
84
+ return stem.startsWith('feature-') ? stem.slice('feature-'.length) : stem
85
+ }
86
+
87
+ /**
88
+ * Places the `Pull request:` line under the origin lines the task already
89
+ * carries, and corrects the number in place when the line exists. A task with
90
+ * no origin line takes it under the H1, which is the only other anchor the
91
+ * board format guarantees.
92
+ */
93
+ export function writePullRequestLine(
94
+ text: string,
95
+ number: number,
96
+ ): { readonly text: string; readonly action: LineAction } {
97
+ const line = `Pull request: #${number}`
98
+ const lines = text.split('\n')
99
+ const existing = lines.findIndex((entry) => entry.startsWith('Pull request:'))
100
+
101
+ if (existing !== -1) {
102
+ if (lines[existing] === line) return { text, action: 'unchanged' }
103
+ lines[existing] = line
104
+ return { text: lines.join('\n'), action: 'corrected' }
105
+ }
106
+
107
+ const origin = lastOriginLine(lines)
108
+ if (origin !== undefined) {
109
+ lines.splice(origin + 1, 0, line)
110
+ return { text: lines.join('\n'), action: 'added' }
111
+ }
112
+
113
+ const heading = lines.findIndex((entry) => entry.startsWith('# '))
114
+ if (heading === -1) return { text: `${line}\n${text}`, action: 'added' }
115
+
116
+ lines.splice(heading + 1, 0, '', line)
117
+ return { text: lines.join('\n'), action: 'added' }
118
+ }
119
+
120
+ function lastOriginLine(lines: readonly string[]): number | undefined {
121
+ let found: number | undefined
122
+
123
+ for (const [index, line] of lines.entries()) {
124
+ if (ORIGIN_PREFIXES.some((prefix) => line.startsWith(prefix))) found = index
125
+ }
126
+
127
+ return found
128
+ }
129
+
130
+ /**
131
+ * Marks outcomes closed by their 1-based position in the task's outcome list,
132
+ * which is the order the board format writes them. A caller reads the file
133
+ * before deciding, so a position is what it already holds, while a text match
134
+ * would need the wording handed back exactly.
135
+ */
136
+ export function closeOutcomeLines(
137
+ text: string,
138
+ positions: readonly number[],
139
+ ): {
140
+ readonly text: string
141
+ readonly closed: readonly string[]
142
+ readonly alreadyClosed: readonly string[]
143
+ readonly total: number
144
+ } {
145
+ const wanted = new Set(positions)
146
+ const closed: string[] = []
147
+ const alreadyClosed: string[] = []
148
+ const lines = text.split('\n')
149
+ const fenced = fenceMask(lines)
150
+ let seen = 0
151
+
152
+ const rewritten = lines.map((line, index) => {
153
+ if (fenced[index]) return line
154
+
155
+ const match = OUTCOME_PATTERN.exec(line)
156
+ if (!match) return line
157
+
158
+ seen += 1
159
+ if (!wanted.has(seen)) return line
160
+
161
+ const [, box, body] = match
162
+ if (box !== ' ') {
163
+ alreadyClosed.push(body.trim())
164
+ return line
165
+ }
166
+
167
+ closed.push(body.trim())
168
+ return body ? `- [x] ${body}` : '- [x]'
169
+ })
170
+
171
+ return { text: rewritten.join('\n'), closed, alreadyClosed, total: seen }
172
+ }
173
+
174
+ async function matchByPlan(
175
+ dir: string,
176
+ stems: readonly string[],
177
+ plan: string,
178
+ ): Promise<string[]> {
179
+ const key = planKey(plan)
180
+
181
+ const read = await Promise.all(
182
+ stems.map(async (stem) => ({
183
+ stem,
184
+ target: readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8')),
185
+ })),
186
+ )
187
+
188
+ return read
189
+ .filter(
190
+ (entry) => entry.target !== undefined && planKey(entry.target) === key,
191
+ )
192
+ .map(({ stem }) => stem)
193
+ }
194
+
195
+ async function resolveStem(
196
+ dir: string,
197
+ selector: RecordSelector,
198
+ ): Promise<string | RecordRefused> {
199
+ const stems = await listTaskStems(dir)
200
+
201
+ if (selector.kind === 'stem') {
202
+ if (!stems.includes(selector.stem)) {
203
+ return refuse(
204
+ 'no-match',
205
+ `No task named ${selector.stem} on the board.`,
206
+ stems,
207
+ )
208
+ }
209
+ return selector.stem
210
+ }
211
+
212
+ const matched = await matchByPlan(dir, stems, selector.plan)
213
+
214
+ if (matched.length === 0) {
215
+ return refuse('no-match', `No task names plan ${selector.plan}.`)
216
+ }
217
+
218
+ if (matched.length > 1) {
219
+ return refuse(
220
+ 'ambiguous',
221
+ `${matched.length} tasks name plan ${selector.plan}. One task, one plan.`,
222
+ matched,
223
+ )
224
+ }
225
+
226
+ return matched[0]
227
+ }
228
+
229
+ async function openTask(
230
+ root: string,
231
+ selector: RecordSelector,
232
+ ): Promise<{ readonly stem: string; readonly path: string } | RecordRefused> {
233
+ const dir = tasksDir(root)
234
+
235
+ if (!existsSync(dir)) {
236
+ return refuse('no-board', `No task board at ${relative(root, dir)}.`)
237
+ }
238
+
239
+ const resolved = await resolveStem(dir, selector)
240
+ if (typeof resolved !== 'string') return resolved
241
+
242
+ return { stem: resolved, path: join(dir, `${resolved}.md`) }
243
+ }
244
+
245
+ /**
246
+ * Records a pull request number on the task the branch closes. `git-pr` runs
247
+ * this from a linked worktree, where an in-place edit through the file-editing
248
+ * tools is refused and a shell stream editor is banned, so the write has to
249
+ * resolve the board root in-process.
250
+ */
251
+ export async function recordPullRequest(
252
+ root: string,
253
+ selector: RecordSelector,
254
+ number: number,
255
+ ): Promise<PullRequestOutcome> {
256
+ const opened = await openTask(root, selector)
257
+ if ('ok' in opened) return opened
258
+
259
+ const { stem, path } = opened
260
+ const { text, action } = writePullRequestLine(
261
+ await readFile(path, 'utf8'),
262
+ number,
263
+ )
264
+
265
+ if (action !== 'unchanged') await writeFile(path, text)
266
+
267
+ return { ok: true, stem, path, number, action }
268
+ }
269
+
270
+ /**
271
+ * Marks the named outcomes `[x]` in place. `claude-docs` runs this against a
272
+ * board it can read and cannot edit from a linked worktree, and the positions
273
+ * come from the read it already made.
274
+ */
275
+ export async function closeOutcomes(
276
+ root: string,
277
+ selector: RecordSelector,
278
+ positions: readonly number[],
279
+ ): Promise<CloseOutcome> {
280
+ const opened = await openTask(root, selector)
281
+ if ('ok' in opened) return opened
282
+
283
+ const { stem, path } = opened
284
+ const result = closeOutcomeLines(await readFile(path, 'utf8'), positions)
285
+
286
+ if (result.total === 0) {
287
+ return refuse('no-outcomes', `${stem} carries no outcomes to close.`)
288
+ }
289
+
290
+ const beyond = positions.filter(
291
+ (position) => position < 1 || position > result.total,
292
+ )
293
+
294
+ if (beyond.length > 0) {
295
+ return refuse(
296
+ 'out-of-range',
297
+ `${stem} carries ${result.total} outcome(s), so ${beyond.join(', ')} names nothing.`,
298
+ beyond.map(String),
299
+ )
300
+ }
301
+
302
+ if (result.closed.length > 0) await writeFile(path, result.text)
303
+
304
+ return {
305
+ ok: true,
306
+ stem,
307
+ path,
308
+ closed: result.closed,
309
+ alreadyClosed: result.alreadyClosed,
310
+ }
311
+ }
@@ -0,0 +1,23 @@
1
+ import { $ } from 'bun'
2
+
3
+ /**
4
+ * Resolves the root every shared-scratch folder lives under. `git worktree
5
+ * list` puts the main worktree first, and trusting the working directory
6
+ * instead would write a second board nothing else reads, or validate a linked
7
+ * worktree's empty folder and report it clean.
8
+ *
9
+ * A caller reaching this from a linked worktree is the case it exists for: the
10
+ * file-editing tools refuse a main-root path there, so a verb resolving the
11
+ * root in-process is the route a skill body has.
12
+ */
13
+ export async function mainWorktreeRoot(): Promise<string> {
14
+ const result = await $`git worktree list --porcelain`.quiet().nothrow()
15
+ if (result.exitCode !== 0) return process.cwd()
16
+
17
+ const line = result.stdout
18
+ .toString()
19
+ .split('\n')
20
+ .find((entry) => entry.startsWith('worktree '))
21
+
22
+ return line ? line.slice('worktree '.length).trim() : process.cwd()
23
+ }
@@ -100,13 +100,8 @@ Only the `development` entry carries this section. It is not a general-purpose h
100
100
  ## Length
101
101
 
102
102
  - Aim for one entry per domain. There is no hard cap. Length is a symptom, not the defect.
103
- - Past roughly 150 rendered lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number.
104
- - Past roughly 40 rendered lines with no heading of any level breaking them, add a subheading at the seam. Measure the longest such run rather than everything under one `##`, and exclude fenced code blocks. The number is a checkpoint like the 150 above, not a cap.
105
- - Both checkpoints count rendered lines, so wrap each source line at 80 columns and sum the heights. Source lines undercount an entry authored one line per bullet, where a block of fifteen paragraph-bullets occupies fifteen lines and renders past sixty. Counting the two checkpoints in different units would put a file measured one way beside a run measured another.
106
- - Exempt a block whose lines are all list items at one level averaging under roughly 130 characters. A flat list of short peers is already navigable, and a subheading dropped into it splits a set that belongs together. Bullet count says nothing on its own, since a catalog of one-liners and a stack of paragraphs reach the same count and read nothing alike, so weight is what decides. Mixing prose with the list, or nesting levels inside it, ends the exemption at any weight.
107
- - Exempt a block whose lines are all table rows, at any length. The peer list above is exempt because it is already navigable, and a table because the remedy does not exist: a subheading dropped inside one splits the table rather than the run, so no edit short of rewriting it as a list clears the checkpoint. Prose either side of the table ends the exemption, since that block has a seam and a heading breaks it there.
108
- - Past roughly 400 characters in one top-level bullet, counting the lines that continue it and excluding any bullet nested under it, check whether the incident that motivated the decision sits beside the decision itself. Keep the current design and the alternative that lost, and move the incident to the change that introduced it, the issue that tracked it, or the research record behind it. The number is a checkpoint like the two above, and a bullet reading well past it means the number is wrong rather than the rule.
109
- - Collapse a stack of bullets narrating one subsystem into a single `###` subsection carrying one narrative. Splitting a heavy bullet into three light ones satisfies the checkpoint above and leaves the reader no better off, and subdividing a block does not lighten the bullets inside it, so the two rules answer different defects.
103
+ - Past roughly 150 rendered lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number. Rendered lines count as `markdown.md` defines them.
104
+ - Where a bullet sits past the weight checkpoint `markdown.md` states, the overflow to move is the incident that motivated the decision, which specializes that rule's instruction to send the overflow to prose. Keep the current design and the alternative that lost, and send the incident to the change that introduced it, the issue that tracked it, or the research record behind it.
110
105
  - Never cut a `## Decisions` or `## Gotchas` entry to shorten a file. Cut a `## Layout` or `## CLI` section instead.
111
106
  - Retire a decision or gotcha once its subject is gone, rewriting the bullet to state the current design rather than leaving the narration of what it replaced beside it. A rejected alternative is not a retired one, so what was tried and why it lost stays whatever its age. The rule above protects content whose subject is live, and this one releases content whose subject is not.
112
107
  - Rewrite a decision a later one replaced rather than appending the replacement beside it. The subject is still live, so the rule above does not reach it, and two bullets on one subject leave a reader to work out which of them is current. State the design that stands and keep the superseded reasoning only where it is the alternative that lost.
@@ -9,7 +9,7 @@ Applies to markdown reference docs, READMEs, and inline documentation in repos.
9
9
 
10
10
  ## Scope
11
11
 
12
- Governs the markdown mechanics of every markdown file: headings, paragraph and list structure, code spans and fences, punctuation, emphasis, and file references. It is an attribute standard rather than a document-type one, so it applies over documents whose shape another standard sets, and it carries no template because mechanics are written across every document and have none of their own to shape.
12
+ Governs the markdown mechanics of every markdown file: headings, paragraph and list structure, code spans and fences, punctuation, emphasis, and file references. It is an attribute standard rather than a document-type one, so it applies over documents whose shape another standard sets, and it carries no template because mechanics are written across every document and have no shape of their own.
13
13
 
14
14
  Does not govern:
15
15
 
@@ -23,12 +23,20 @@ Does not govern:
23
23
  - H1 for document title, H2 for main sections, H3 for subsections
24
24
  - Use sentence case for all headings (H1, H2, H3)
25
25
  - Proper nouns and product names retain their casing in headings
26
+ - Past roughly 40 rendered lines with no heading of any level breaking them, add a subheading at the seam. Measure the longest such run rather than everything under one `##`, and exclude fenced code blocks. The number is a checkpoint, not a cap.
27
+ - Count rendered lines rather than source lines, wrapping each source line at 80 columns and summing the heights. Source lines undercount a file authored one line per bullet, where a block of fifteen paragraph-bullets occupies fifteen lines and renders past sixty. A checkpoint another standard states counts the same unit, so a file measured one way never sits beside a run measured another.
28
+ - Exempt a block whose lines are all list items at one level averaging under roughly 130 characters. A flat list of short peers is already navigable, and a subheading dropped into it splits a set that belongs together. Bullet count says nothing on its own, since a catalog of one-liners and a stack of paragraphs reach the same count and read nothing alike, so weight is what decides.
29
+ - Mixing prose with that list, or nesting levels inside it, ends the exemption at any weight.
30
+ - Exempt a block whose lines are all table rows, at any length. The peer list above is exempt because it is already navigable, and a table because the remedy does not exist: a subheading dropped inside one splits the table rather than the run, so no edit short of rewriting it as a list clears the checkpoint.
31
+ - Prose either side of the table ends that exemption, since the block has a seam and a heading breaks it there.
26
32
 
27
33
  ## Paragraphs and lists
28
34
 
29
35
  - Use prose by default. Reserve bullets for discrete, unrelated items.
30
36
  - Keep paragraphs to four sentences or fewer. Split longer blocks at the next logical boundary.
31
- - Keep bullets tight. If a bullet needs more than a couple of sentences, it belongs in prose.
37
+ - Past roughly 400 characters in one paragraph, folding in the lines that wrap it, split at the next logical boundary as well. The sentence cap alone is satisfied by writing fewer and longer sentences, and measured across this corpus 344 paragraphs sit inside four sentences and past this number, so a count on its own passes every one of them.
38
+ - Keep bullets tight. Past roughly 400 characters in one top-level bullet, counting the lines that continue it and excluding any bullet nested under it, the overflow belongs in prose. The number is a checkpoint rather than a cap, and a bullet reading well past it means the number is wrong rather than the rule.
39
+ - Collapse a stack of bullets narrating one subsystem into a single `###` subsection carrying one narrative. Splitting a heavy bullet into three light ones satisfies the checkpoint above and leaves the reader no better off, and subdividing a block does not lighten the bullets inside it, so the two rules answer different defects.
32
40
  - Use dashes (`-`) not asterisks (`*`) for bulleted lists
33
41
  - Do not end single-sentence or fragment bullets with a period. Use periods when a bullet has two or more sentences.
34
42
  - For key path lists, use colon format: `- \`src/\`: description`. Never use an em dash.
@@ -87,3 +87,4 @@
87
87
  - From a linked worktree, every `Edit` or `Write` to a tracked file (source, docs) must use a path starting with `pwd`.
88
88
  - From a linked worktree, `Edit` and `Write` are refused for every main-root path, session scratch included. The refusal names session isolation and points at the worktree copy, which is a second gitignored file no later session reads, so never take that redirect.
89
89
  - `Read` resolves against the main root normally from a linked worktree. A main-root write reaches it only through `Bash`, as one plain command rather than a compound one, which is refused for complexity.
90
+ - Route a main-root write by what it does to the file. Creating a whole file goes out as one plain `Bash` command carrying a heredoc. Changing a line inside a file that already exists goes through an `aitk` verb, which resolves the main root in-process, because the shell route for that case is the stream editor this file bans.