@erclx/aitk 0.44.0 → 0.46.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-autoship/SKILL.md +15 -10
- package/claude/skills/claude-docs/SKILL.md +16 -2
- package/claude/skills/claude-memory-capture/REQUIREMENT.md +14 -4
- package/claude/skills/claude-memory-capture/SKILL.md +45 -16
- package/claude/skills/claude-memory-review/REQUIREMENT.md +8 -2
- package/claude/skills/claude-memory-review/SKILL.md +41 -25
- package/claude/skills/claude-orchestrate/SKILL.md +5 -0
- package/claude/skills/claude-seed-sync/REQUIREMENT.md +3 -1
- package/claude/skills/claude-seed-sync/SKILL.md +16 -4
- package/claude/skills/git-ship/SKILL.md +14 -11
- package/claude/skills/session-resume/SKILL.md +2 -2
- package/claude/skills/toolkit-operator/SKILL.md +18 -1
- package/docs/agents/commands.md +36 -35
- package/docs/agents/index.md +1 -0
- package/docs/agents/indexes.md +3 -1
- package/docs/agents/install-and-sync.md +46 -0
- package/docs/agents/scripting.md +4 -3
- package/docs/agents/skills-audit.md +55 -0
- package/docs/ai-workflow.md +3 -2
- package/docs/target-projects.md +5 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +8 -0
- package/src/claude/seeds.ts +7 -1
- package/src/claude/skills-audit.ts +215 -0
- package/src/claude/skills-list.ts +3 -3
- package/src/commands/claude.ts +283 -5
- package/src/commands/context.ts +1 -4
- package/src/commands/sync.ts +54 -1
- package/src/sync/check.ts +71 -0
- package/src/sync/layout.ts +139 -0
- package/src/sync/seeds-report.ts +111 -0
- package/src/ui.ts +5 -0
- package/tooling/claude/reference.md +11 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +60 -0
- package/tooling/claude/seeds/.claude/memory/index.md +8 -0
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/claude/seeds/CLAUDE.md +3 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { SUBDIRS } from '@/claude/seeds'
|
|
4
|
+
import { snippetsSourceDir } from '@/snippets/categories'
|
|
5
|
+
import { standardsSourceDir } from '@/standards/adapter'
|
|
6
|
+
import type { StampDomain } from '@/sync/stamp'
|
|
7
|
+
|
|
8
|
+
const CLAUDE_DIR = '.claude'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Domains an older toolkit installed at the project root, each with the source
|
|
12
|
+
* folder naming what it owns. Governance is absent because its rules have always
|
|
13
|
+
* landed under `.claude/rules/`, so there is no earlier location to be stranded
|
|
14
|
+
* at.
|
|
15
|
+
*
|
|
16
|
+
* A tuple array rather than a partial record, so the domain key stays typed
|
|
17
|
+
* without asserting an `Object.entries` result back into the union.
|
|
18
|
+
*/
|
|
19
|
+
const ROOT_LAYOUTS: readonly (readonly [
|
|
20
|
+
StampDomain,
|
|
21
|
+
string,
|
|
22
|
+
(root: string) => string,
|
|
23
|
+
])[] = [
|
|
24
|
+
['standards', 'standards', standardsSourceDir],
|
|
25
|
+
['snippets', 'snippets', snippetsSourceDir],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A target file that a shipped seed folder replaced. Carries no source and
|
|
30
|
+
* queues no change, because the file holds content the project wrote and only
|
|
31
|
+
* the user can decide where it moves.
|
|
32
|
+
*/
|
|
33
|
+
export interface SupersededEntry {
|
|
34
|
+
readonly rel: string
|
|
35
|
+
readonly replacedBy: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A domain whose files sit at the root layout an older toolkit installed to,
|
|
40
|
+
* with nothing at the path the current one reads. Distinct from a domain that
|
|
41
|
+
* was never installed, which has neither.
|
|
42
|
+
*/
|
|
43
|
+
export interface UnmigratedDomain {
|
|
44
|
+
readonly domain: StampDomain
|
|
45
|
+
readonly rootPath: string
|
|
46
|
+
readonly installPath: string
|
|
47
|
+
/** Root files the toolkit ships under this domain, not every file present. */
|
|
48
|
+
readonly files: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pairs each seed subdirectory against an uppercase-stem sibling in the target,
|
|
53
|
+
* so a project still holding `.claude/TASKS.md` is reported against the
|
|
54
|
+
* `.claude/tasks/` folder that replaced it.
|
|
55
|
+
*
|
|
56
|
+
* Deriving from the seed tree rather than from a fixed list means a folder
|
|
57
|
+
* added later is covered without editing this file. The cost is that only an
|
|
58
|
+
* exact stem matches, so a suffixed variant such as `TASKS-ARCHIVE.md` is not
|
|
59
|
+
* reported.
|
|
60
|
+
*/
|
|
61
|
+
export function collectSuperseded(target: string): SupersededEntry[] {
|
|
62
|
+
const entries: SupersededEntry[] = []
|
|
63
|
+
|
|
64
|
+
for (const subdir of SUBDIRS) {
|
|
65
|
+
const rel = join(CLAUDE_DIR, `${subdir.toUpperCase()}.md`)
|
|
66
|
+
if (!isFile(join(target, rel))) continue
|
|
67
|
+
|
|
68
|
+
entries.push({ rel, replacedBy: join(CLAUDE_DIR, subdir) })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Domains the target holds at the root rather than under `.claude/`. Reported
|
|
76
|
+
* separately from the per-domain scan because that scan lists only domains it
|
|
77
|
+
* finds installed, so an unmigrated project would otherwise read as one that
|
|
78
|
+
* never installed the domain at all.
|
|
79
|
+
*
|
|
80
|
+
* A root folder is claimed only when it holds a file the toolkit ships under
|
|
81
|
+
* that domain. Presence of the folder alone is not evidence: a project can
|
|
82
|
+
* carry its own `standards/` of project docs and never have installed the
|
|
83
|
+
* domain, and calling that unmigrated would fail `--exit-code` with no action
|
|
84
|
+
* that clears it.
|
|
85
|
+
*/
|
|
86
|
+
export function detectUnmigrated(
|
|
87
|
+
toolkitRoot: string,
|
|
88
|
+
target: string,
|
|
89
|
+
): UnmigratedDomain[] {
|
|
90
|
+
const found: UnmigratedDomain[] = []
|
|
91
|
+
|
|
92
|
+
for (const [domain, rootPath, sourceDir] of ROOT_LAYOUTS) {
|
|
93
|
+
const installPath = join(CLAUDE_DIR, rootPath)
|
|
94
|
+
if (isDirectoryWithFiles(join(target, installPath))) continue
|
|
95
|
+
|
|
96
|
+
const files = countToolkitOwned(
|
|
97
|
+
join(target, rootPath),
|
|
98
|
+
sourceDir(toolkitRoot),
|
|
99
|
+
)
|
|
100
|
+
if (files === 0) continue
|
|
101
|
+
|
|
102
|
+
found.push({ domain, rootPath, installPath, files })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return found
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Root files whose basename matches something the toolkit ships for this domain.
|
|
110
|
+
* Basenames rather than relative paths, because standards install flat while
|
|
111
|
+
* snippets nest by category, and the question here is only whether any file is
|
|
112
|
+
* toolkit-owned rather than which source each one came from.
|
|
113
|
+
*/
|
|
114
|
+
function countToolkitOwned(dir: string, sourceDir: string): number {
|
|
115
|
+
const owned = new Set(listMarkdown(sourceDir).map((rel) => basename(rel)))
|
|
116
|
+
if (owned.size === 0) return 0
|
|
117
|
+
|
|
118
|
+
return listMarkdown(dir).filter((rel) => owned.has(basename(rel))).length
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isFile(path: string): boolean {
|
|
122
|
+
return existsSync(path) && statSync(path).isFile()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isDirectoryWithFiles(path: string): boolean {
|
|
126
|
+
return listMarkdown(path).length > 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function listMarkdown(dir: string): string[] {
|
|
130
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
...new Bun.Glob('**/*.md').scanSync({
|
|
134
|
+
cwd: dir,
|
|
135
|
+
onlyFiles: true,
|
|
136
|
+
dot: true,
|
|
137
|
+
}),
|
|
138
|
+
]
|
|
139
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { planSeeds, type Seed } from '@/claude/seeds'
|
|
4
|
+
import { findInstalledOrigin, readHistoryIndex } from '@/sync/history'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How an installed seed compares to the seed the toolkit currently ships.
|
|
8
|
+
*
|
|
9
|
+
* `missing` has no counterpart in the domain scan, which walks what a target
|
|
10
|
+
* installed and so cannot see a file that never arrived. Seeds are enumerated
|
|
11
|
+
* from the source instead, which is what makes the absence legible.
|
|
12
|
+
*
|
|
13
|
+
* There is no `customized` here. That verdict needs a stamp, and seeds carry
|
|
14
|
+
* none, so a file history cannot attribute stays `drifted` and a consumer reads
|
|
15
|
+
* it as the local edit it almost always is.
|
|
16
|
+
*/
|
|
17
|
+
export type SeedState = 'matching' | 'stale' | 'drifted' | 'missing'
|
|
18
|
+
|
|
19
|
+
export interface SeedReportEntry {
|
|
20
|
+
readonly state: SeedState
|
|
21
|
+
readonly rel: string
|
|
22
|
+
/** Toolkit revision this file's content came from, when history proved it. */
|
|
23
|
+
readonly since?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SeedsReport {
|
|
27
|
+
readonly entries: readonly SeedReportEntry[]
|
|
28
|
+
/** Set when a file needed history to attribute it and this toolkit has none. */
|
|
29
|
+
readonly historyUnavailable: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classifies every seed the toolkit ships against the target's copy, and never
|
|
34
|
+
* returns a change. Seeds are copy-once files a project is expected to edit, so
|
|
35
|
+
* the engine's copy path would overwrite `CLAUDE.md` wholesale. Reporting alone
|
|
36
|
+
* is what lets `claude-seed-sync` merge one section at a time instead.
|
|
37
|
+
*
|
|
38
|
+
* Attribution reuses the history reader rather than the engine's own recovery
|
|
39
|
+
* pass, which is private and takes a `SyncAdapter` seeds have no way to supply.
|
|
40
|
+
*/
|
|
41
|
+
export function buildSeedsReport(
|
|
42
|
+
toolkitRoot: string,
|
|
43
|
+
target: string,
|
|
44
|
+
): SeedsReport {
|
|
45
|
+
const differing: DifferingSeed[] = []
|
|
46
|
+
const entries: SeedReportEntry[] = []
|
|
47
|
+
|
|
48
|
+
for (const { seed, present } of planSeeds(toolkitRoot, target)) {
|
|
49
|
+
const rel = relative(target, seed.dest)
|
|
50
|
+
|
|
51
|
+
if (!present) {
|
|
52
|
+
entries.push({ state: 'missing', rel })
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (sameContent(seed.src, seed.dest)) {
|
|
57
|
+
entries.push({ state: 'matching', rel })
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
differing.push({ index: entries.length, seed })
|
|
62
|
+
entries.push({ state: 'drifted', rel })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const historyUnavailable = attribute(toolkitRoot, entries, differing)
|
|
66
|
+
|
|
67
|
+
return { entries, historyUnavailable }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface DifferingSeed {
|
|
71
|
+
readonly index: number
|
|
72
|
+
readonly seed: Seed
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Second pass over the seeds that differ, matching installed content against
|
|
77
|
+
* every version the toolkit ever published. A match proves the file is
|
|
78
|
+
* untouched since it landed, so the toolkit is what moved and the entry becomes
|
|
79
|
+
* `stale`. Runs as one git call for the whole set rather than one per file.
|
|
80
|
+
*/
|
|
81
|
+
function attribute(
|
|
82
|
+
toolkitRoot: string,
|
|
83
|
+
entries: SeedReportEntry[],
|
|
84
|
+
differing: readonly DifferingSeed[],
|
|
85
|
+
): boolean {
|
|
86
|
+
if (differing.length === 0) return false
|
|
87
|
+
|
|
88
|
+
const index = readHistoryIndex(
|
|
89
|
+
toolkitRoot,
|
|
90
|
+
differing.map((file) => relative(toolkitRoot, file.seed.src)),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (index === undefined) return true
|
|
94
|
+
|
|
95
|
+
for (const file of differing) {
|
|
96
|
+
const since = findInstalledOrigin(
|
|
97
|
+
index,
|
|
98
|
+
relative(toolkitRoot, file.seed.src),
|
|
99
|
+
file.seed.dest,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if (since === undefined) continue
|
|
103
|
+
entries[file.index] = { ...entries[file.index], state: 'stale', since }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sameContent(source: string, dest: string): boolean {
|
|
110
|
+
return readFileSync(source).equals(readFileSync(dest))
|
|
111
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -59,6 +59,11 @@ export function pipeOutput(text: string): void {
|
|
|
59
59
|
)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** Counts a noun for a report line, where the plural is the bare `s` form. */
|
|
63
|
+
export function plural(count: number, noun: string): string {
|
|
64
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
export function frameError(message: string): void {
|
|
63
68
|
process.stderr.write(
|
|
64
69
|
`${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${message}\n${GREY}└${NC}\n`,
|
|
@@ -20,7 +20,7 @@ The claude stack installs the `.claude/` workflow directory into a project. Stat
|
|
|
20
20
|
├── plans/ ← execution detail for multi-step tasks, gitignored. `feature-*.md` entries swept by claude-docs.
|
|
21
21
|
├── review/ ← scratch for claude-review and claude-ui-test output, gitignored
|
|
22
22
|
├── .tmp/ ← ephemeral scratch space, gitignored
|
|
23
|
-
└── memory/ ← session
|
|
23
|
+
└── memory/ ← session facts no context entry owns, gitignored. `index.md` regenerated by a hook.
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
## Upgrading from a single-file board
|
|
@@ -33,6 +33,16 @@ Convert by hand, once per project:
|
|
|
33
33
|
2. Run `aitk indexes regen --no-stage --root . .claude/tasks/<any-task>.md` to build the catalog.
|
|
34
34
|
3. Delete `.claude/TASKS.md`, and swap its `.gitignore` entry for `.claude/tasks/`.
|
|
35
35
|
|
|
36
|
+
## Upgrading a hand-appended memory index
|
|
37
|
+
|
|
38
|
+
A project installed before the memory folder gained a generated index still holds `.claude/memory/MEMORY.md`, and its entries still carry `name` and `type` frontmatter. Nothing migrates it. `claude-memory-capture` stops appending rows once the new seed lands, so the old file freezes at whatever it held while the folder keeps growing past it.
|
|
39
|
+
|
|
40
|
+
Convert by hand, once per project:
|
|
41
|
+
|
|
42
|
+
1. Rewrite each entry's `name` key to `title` and its `type` key to a sentence-case `category`, quoting any `description` that opens with a backtick or a colon so the frontmatter parses.
|
|
43
|
+
2. Replace `MEMORY.md` with an `index.md` carrying `title` and `subtitle` frontmatter and nothing else.
|
|
44
|
+
3. Run `aitk indexes regen --no-stage --root . .claude/memory/index.md` to build the catalog, and compare its entry count against the file count before deleting anything.
|
|
45
|
+
|
|
36
46
|
## Upgrading from a single-file diagram set
|
|
37
47
|
|
|
38
48
|
A project installed before the diagram surface became a folder still holds `.claude/DIAGRAMS.md`. Unlike the board, this one migrates itself. The `claude-diagram` skill reads the flat file when `.claude/diagrams/` holds no entries, splits it by kind into the folder, and reports what it wrote. The old file stays on disk so the split can be compared against its source, and deleting it is a manual step once that check passes.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Regenerates .claude/memory/index.md after a memory file changes.
|
|
4
|
+
#
|
|
5
|
+
# The memory folder is gitignored, so the whole-repo walk in `bun run check`
|
|
6
|
+
# drops it and never regenerates this index. A positional path bypasses that
|
|
7
|
+
# filter, which makes this hook the only trigger that reaches the folder.
|
|
8
|
+
|
|
9
|
+
input=$(cat)
|
|
10
|
+
|
|
11
|
+
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
|
|
12
|
+
case "$tool" in
|
|
13
|
+
Write | Edit | MultiEdit) ;;
|
|
14
|
+
*) exit 0 ;;
|
|
15
|
+
esac
|
|
16
|
+
|
|
17
|
+
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
|
|
18
|
+
[ -n "$file_path" ] || exit 0
|
|
19
|
+
|
|
20
|
+
case "$file_path" in
|
|
21
|
+
*/.claude/memory/*.md) ;;
|
|
22
|
+
*) exit 0 ;;
|
|
23
|
+
esac
|
|
24
|
+
|
|
25
|
+
case "$file_path" in
|
|
26
|
+
*/.claude/memory/index.md) exit 0 ;;
|
|
27
|
+
esac
|
|
28
|
+
|
|
29
|
+
# Report a missing CLI rather than exiting quietly. The path guard above already
|
|
30
|
+
# scopes this to a memory-file edit, so the message only fires where the stale
|
|
31
|
+
# index it warns about is the actual outcome.
|
|
32
|
+
if ! command -v aitk >/dev/null 2>&1; then
|
|
33
|
+
jq -nc --arg msg 'aitk is not on PATH, so .claude/memory/index.md was not regenerated and is now stale. Install the toolkit CLI or run aitk indexes regen by hand.' \
|
|
34
|
+
'{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# The walk-up boundary has to come from the path, not from the session. Shared
|
|
39
|
+
# scratch resolves at the main worktree root, so a session inside a linked
|
|
40
|
+
# worktree passes a path that sits outside its own project directory and the
|
|
41
|
+
# default boundary would reject it.
|
|
42
|
+
root="${file_path%/.claude/memory/*}"
|
|
43
|
+
[ -n "$root" ] || exit 0
|
|
44
|
+
|
|
45
|
+
# `--no-stage` because a hook has no business touching the index. On a project
|
|
46
|
+
# whose memory folder is not gitignored, the default auto-stage would silently
|
|
47
|
+
# add memory files to whatever commit is being assembled.
|
|
48
|
+
output=$(aitk indexes regen --no-stage --root "$root" "$file_path" 2>&1) && exit 0
|
|
49
|
+
|
|
50
|
+
# Regen failed, which on this folder means a memory file is missing `title` or
|
|
51
|
+
# `description`. Report it. Nothing else can: the folder is gitignored, so the
|
|
52
|
+
# whole-repo walk never reaches it and no gate stage will ever fail on a stale
|
|
53
|
+
# index. Staying quiet here is what makes the drift permanent.
|
|
54
|
+
errors=$(printf '%s\n' "$output" | grep '^ERROR: ' | head -5)
|
|
55
|
+
[ -n "$errors" ] || errors="$output"
|
|
56
|
+
|
|
57
|
+
msg="Memory index regen failed, so .claude/memory/index.md is now stale. Fix the frontmatter and save again. $errors"
|
|
58
|
+
jq -nc --arg msg "$msg" \
|
|
59
|
+
'{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
|
|
60
|
+
exit 0
|
|
@@ -72,9 +72,12 @@
|
|
|
72
72
|
## Memory
|
|
73
73
|
|
|
74
74
|
- Write all memory files to `.claude/memory/`, not `~/.claude/projects/`
|
|
75
|
+
- A fact about a domain goes to that domain's `.claude/context/` entry, not to memory. `claude-memory-capture` routes it there and `claude-docs` folds it in. Memory keeps only what no context entry owns.
|
|
75
76
|
- Save a feedback memory only when the same mistake happens twice in the session, or when the user explicitly corrects you. First-occurrence slips are noise.
|
|
76
77
|
- Keep feedback memories to 3 lines: the rule, a one-line Why, and a one-line How to apply. Capture the pattern, not the recovery narrative.
|
|
77
78
|
- Before creating a new memory file, check for an existing one on the same topic. Update rather than duplicate.
|
|
79
|
+
- Give every entry `title`, `description`, and a sentence-case `category`. Never hand-edit `.claude/memory/index.md`. A hook regenerates it from sibling frontmatter.
|
|
80
|
+
- Never delete a memory entry. `claude-memory-review` moves a retired one to `.claude/.tmp/memory-archive/`.
|
|
78
81
|
|
|
79
82
|
## Scratch
|
|
80
83
|
|