@erclx/aitk 0.66.1 → 0.67.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-worktree/REQUIREMENT.md +8 -5
- package/claude/skills/claude-worktree/SKILL.md +24 -4
- package/claude/skills/claude-worktree/references/branch.md +60 -0
- package/claude/skills/git-branch/references/branch.md +1 -1
- package/claude/skills/git-followup/REQUIREMENT.md +3 -3
- package/claude/skills/git-followup/SKILL.md +3 -2
- package/claude/skills/git-pr/references/branch.md +1 -1
- package/claude/skills/git-split/references/branch.md +1 -1
- package/docs/agents/install-and-sync.md +23 -0
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +3 -3
- package/package.json +1 -1
- package/src/commands/sync.ts +51 -6
- package/src/commands/tooling.ts +35 -0
- package/src/sync/check.ts +125 -8
- package/src/sync/stamp.ts +64 -4
- package/src/tooling/stamp.ts +44 -0
- package/standards/bundled/branch.md +1 -1
- package/standards/slug.md +4 -2
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: claude-worktree
|
|
3
|
-
description: Why worktree entry is wrapped rather than called directly, covering name derivation, the branch rename
|
|
3
|
+
description: Why worktree entry is wrapped rather than called directly, covering name derivation, the conventional branch rename the ship chain depends on, and the shared-config repair
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Claude worktree requirement
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, the user names the worktree by hand, and a name matching no plan breaks every slug derived from it afterward. The entry tool then creates the branch as `worktree-<name>`, which diverges from the name the folder carries, so the ship chain looks for a plan file under a slug that does not exist and stops with nothing wrong except the name.
|
|
10
|
+
Without this skill, the user names the worktree by hand, and a name matching no plan breaks every slug derived from it afterward. The entry tool then creates the branch as `worktree-<name>`, which diverges from the name the folder carries, so the ship chain looks for a plan file under a slug that does not exist and stops with nothing wrong except the name. Correcting that to the bare name leaves the branch carrying no type, which `git-pr` refuses on format, so the same run stops a second time with the work already done and uncommitted.
|
|
11
11
|
|
|
12
12
|
Entry also writes the bare flag into the shared config, which strands the main worktree. Every command run there fails while the files sit untouched on disk, and the linked worktree keeps working, so nothing surfaces until the operator returns to the main checkout and finds the repository broken. A rename onto a branch that already exists is the third failure, and it is the one that destroys work rather than blocking it.
|
|
13
13
|
|
|
@@ -16,7 +16,9 @@ Entry also writes the bare flag into the shared config, which strands the main w
|
|
|
16
16
|
- Derive the name from the plan matched to the current branch, falling through the ordered sources rather than picking
|
|
17
17
|
- Validate and sanitize the derived name against what the entry tool accepts
|
|
18
18
|
- Preview the resolved name and which source produced it before entering
|
|
19
|
-
- Rename the created branch to
|
|
19
|
+
- Rename the created branch to a conventional `<type>/<name>`, so the pull request guard accepts it and the slug transform still reads `<name>` back out
|
|
20
|
+
- Take the type from the plan the name came from, and default to `feat` when a branch or the user supplied it
|
|
21
|
+
- Test the target branch and the target directory before entering, so a stop costs no worktree
|
|
20
22
|
- Read the bare flag before writing it, and repair it on both sides of entry
|
|
21
23
|
- Announce the repair only when a write actually happened
|
|
22
24
|
|
|
@@ -24,7 +26,7 @@ Entry also writes the bare flag into the shared config, which strands the main w
|
|
|
24
26
|
|
|
25
27
|
- Pick between plans when more than one could match. Ask.
|
|
26
28
|
- Enter on a name inferred from session context without confirmation
|
|
27
|
-
- Delete or overwrite a branch that already carries the target name
|
|
29
|
+
- Delete or overwrite a branch or a worktree directory that already carries the target name
|
|
28
30
|
- Rename when the worktree was entered by path, since that branch already has its own identity
|
|
29
31
|
- Invoke the exit path, which is the user's call
|
|
30
32
|
|
|
@@ -32,7 +34,8 @@ Entry also writes the bare flag into the shared config, which strands the main w
|
|
|
32
34
|
|
|
33
35
|
- Already inside a linked worktree: stop rather than nesting
|
|
34
36
|
- Not a git repository and no creation hook configured: stop
|
|
35
|
-
- Target branch already exists: stop and leave it alone, since resolving it automatically risks the wrong branch
|
|
37
|
+
- Target branch already exists: stop before entering and leave it alone, since resolving it automatically risks the wrong branch
|
|
38
|
+
- Target worktree directory already exists: stop before entering, which is the only read that sees two branches differing by type collapsing onto one name
|
|
36
39
|
|
|
37
40
|
## Out of scope
|
|
38
41
|
|
|
@@ -43,28 +43,48 @@ Try each source in order. Stop at the first match.
|
|
|
43
43
|
|
|
44
44
|
Validate the result: letters, digits, dots, underscores, dashes only, max 64 chars (`/` separators are also allowed). If the derived name violates the rule, sanitize by replacing invalid chars with `-` and truncating. Show the sanitized name in the preview before invoking.
|
|
45
45
|
|
|
46
|
+
Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it. A name from a plan takes the type that plan's own work carries, read off its `## Summary` and `**Files to touch:**` lines. Every other case takes `feat`, which covers a name from a branch, a name from the user, and a plan whose lines settle nothing. Draw the value from the type vocabulary in `${CLAUDE_SKILL_DIR}/references/branch.md`.
|
|
47
|
+
|
|
48
|
+
A wrong type is cheap. `git-branch` renames to conventional format later in the same chain and runs ahead of `git-pr`, so a `feat/` written over a fix is corrected before any pull request opens.
|
|
49
|
+
|
|
50
|
+
Then test both names the entry is about to claim. Neither read needs a worktree, and a stop after Step 4 leaves one built with the session sitting inside it, so both belong here rather than beside the rename:
|
|
51
|
+
|
|
52
|
+
- Branch. `git show-ref --verify --quiet refs/heads/<type>/<name>` succeeding means the ref exists. Stop: `❌ Branch <type>/<name> already exists. Resolve manually before continuing.`
|
|
53
|
+
- Directory. `<main-root>/.claude/worktrees/<name>/` existing means an earlier entry claimed the name. Stop: `❌ Worktree .claude/worktrees/<name>/ already exists. Resolve manually before continuing.`
|
|
54
|
+
|
|
55
|
+
Leave both in place. Resolving either automatically risks the wrong one.
|
|
56
|
+
|
|
57
|
+
The two tests catch different collisions. The branch test misses the one `.claude/standards/slug.md` records, where two branches differing only in type collapse onto one name: `feat/foo` and `fix/foo` are distinct refs and reach one directory. The directory test is the only read that sees it.
|
|
58
|
+
|
|
59
|
+
The branch test fires on the tier 1 and tier 4 sources whenever the branch the session started on is already conventional, since a name derived from that branch resolves back onto it. Stopping is the answer there. The concern already has a branch, git refuses a second under the same name, and the bare-name rename this replaces only carried the collision forward to the `git-branch` step.
|
|
60
|
+
|
|
46
61
|
## Step 3: preview
|
|
47
62
|
|
|
48
63
|
Output exactly:
|
|
49
64
|
|
|
50
65
|
```plaintext
|
|
51
66
|
Worktree: .claude/worktrees/<name>/
|
|
52
|
-
|
|
67
|
+
Branch: <type>/<name>
|
|
68
|
+
Source: <plan|branch|user>
|
|
53
69
|
```
|
|
54
70
|
|
|
71
|
+
Step 2 resolved `<type>`. Show it, since it is the one part of the entry the user cannot read off the name they supplied.
|
|
72
|
+
|
|
55
73
|
## Step 4: enter
|
|
56
74
|
|
|
57
75
|
Call `EnterWorktree` with `name: "<name>"`. Claude Code's tool permission dialog is the confirmation gate. Do not pause for additional confirmation.
|
|
58
76
|
|
|
59
77
|
## Step 5: align the branch name and repair the shared config
|
|
60
78
|
|
|
61
|
-
`EnterWorktree` creates a branch named `worktree-<name>`, which diverges from `<name>` and breaks downstream slug derivation in `claude-autoship` and any skill that reads `git branch --show-current`. Rename
|
|
79
|
+
`EnterWorktree` creates a branch named `worktree-<name>`, which diverges from `<name>` and breaks downstream slug derivation in `claude-autoship` and any skill that reads `git branch --show-current`. A bare `<name>` fixes that and fails the branch-format guard in `git-pr`, which requires `<type>/<description>`. Rename to the conventional form, which satisfies both:
|
|
62
80
|
|
|
63
81
|
```bash
|
|
64
|
-
git branch -m worktree-<name> <name>
|
|
82
|
+
git branch -m worktree-<name> <type>/<name>
|
|
65
83
|
```
|
|
66
84
|
|
|
67
|
-
|
|
85
|
+
The slug transform drops a leading type segment, so `<name>` is what every downstream derivation reads back out of the typed branch. See `.claude/standards/slug.md`, or `${CLAUDE_SKILL_DIR}/../../standards/slug.md` when the project does not have it.
|
|
86
|
+
|
|
87
|
+
Step 2 already cleared the target name, so the rename runs unguarded here. Do not repeat the test. A ref created between the two points is a second session racing this one, which a re-read narrows rather than closes.
|
|
68
88
|
|
|
69
89
|
Skip the rename if the worktree was entered via `path` rather than `name`, since the branch already exists under its own identity.
|
|
70
90
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Branch reference
|
|
3
|
+
description: Branch naming format and type conventions
|
|
4
|
+
consumers: git-branch, git-split, git-pr, claude-worktree
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Branch reference
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
Governs a git branch name: its structure, its length, and the type vocabulary it draws from.
|
|
12
|
+
|
|
13
|
+
Does not govern:
|
|
14
|
+
|
|
15
|
+
- Commit subject format, which shares the type vocabulary: `commit.md`
|
|
16
|
+
- Pull request title and body: `pr.md`
|
|
17
|
+
- Whether a phase label may appear in a branch name: `versioning.md`
|
|
18
|
+
- Deriving a slug from a branch name for use in an output filename: `slug.md`
|
|
19
|
+
|
|
20
|
+
## Format
|
|
21
|
+
|
|
22
|
+
- Structure: `<type>/<description>` or `<type>/<ticket>-<description>`
|
|
23
|
+
- Length: 50 characters maximum
|
|
24
|
+
- Casing: kebab-case only, no underscores or camelCase
|
|
25
|
+
- Description: 2 words maximum, 3 only when genuinely needed for specificity
|
|
26
|
+
- Capture the core change, not the commit message verbatim
|
|
27
|
+
- For branches with multiple commits, use the unifying concern as the description.
|
|
28
|
+
- Do not duplicate type in description (e.g., `feat/feature-login`)
|
|
29
|
+
|
|
30
|
+
## Types
|
|
31
|
+
|
|
32
|
+
- `feat`: new feature or capability
|
|
33
|
+
- `fix`: bug fix
|
|
34
|
+
- `refactor`: structural changes (not a fix or feature)
|
|
35
|
+
- `docs`: documentation only (README)
|
|
36
|
+
- `chore`: maintenance tasks (deps, tooling, configs)
|
|
37
|
+
- `perf`: performance improvements
|
|
38
|
+
- `test`: add or modify tests
|
|
39
|
+
- `style`: code formatting (whitespace, semicolons)
|
|
40
|
+
- `build`: build system changes (webpack, npm scripts)
|
|
41
|
+
- `ci`: CI/CD pipeline changes (GitHub Actions)
|
|
42
|
+
- `revert`: revert a previous commit
|
|
43
|
+
|
|
44
|
+
## Examples
|
|
45
|
+
|
|
46
|
+
### Correct
|
|
47
|
+
|
|
48
|
+
```plaintext
|
|
49
|
+
feat/jwt-expiration # clear feature scope
|
|
50
|
+
fix/AUTH-123-connection-pool # includes ticket ID
|
|
51
|
+
refactor/remove-deprecated-endpoints # clear refactor intent
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Incorrect
|
|
55
|
+
|
|
56
|
+
```plaintext
|
|
57
|
+
feature/auth_stuff # wrong type + underscore
|
|
58
|
+
feat/feature-add-login # duplicates type in description
|
|
59
|
+
fix/DB-456-fix-the-database-connection-pool-memory-leak # exceeds 50 chars + verbatim message
|
|
60
|
+
```
|
|
@@ -7,11 +7,12 @@ description: What the follow-up push is for, the gaps it closes, and how it spli
|
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, an edit made after a pull request is already open ships as a bare push. The body still describes the scope from before the edit, so a reviewer returning to the page reads a description the diff no longer matches. A reviewer who left comments gets no reply, and when a caller has already posted its own reply, a second one lands underneath it. A caller that rebased the branch before handing over hits a rejected push, since the tracking branch no longer reaches the head.
|
|
10
|
+
Without this skill, an edit made after a pull request is already open ships as a bare push. The body still describes the scope from before the edit, so a reviewer returning to the page reads a description the diff no longer matches. A reviewer who left comments gets no reply, and when a caller has already posted its own reply, a second one lands underneath it. A caller that rebased the branch before handing over hits a rejected push, since the tracking branch no longer reaches the head. A branch entered through a worktree hits the opposite shape, carrying an open pull request with no tracking ref at all, which read as a branch that had never been pushed.
|
|
11
11
|
|
|
12
12
|
## Must
|
|
13
13
|
|
|
14
|
-
- Refuse unless the branch has an
|
|
14
|
+
- Refuse unless the branch has an open pull request, since every later step addresses it
|
|
15
|
+
- Set the tracking ref at the push when the branch has none, since a branch entered through a worktree carries an open pull request without one
|
|
15
16
|
- Delegate the message to `git-commit` rather than composing one, so a follow-up commit reads like every other commit
|
|
16
17
|
- Sync the body when the new commit moves the scope, and the title when the shift makes it inaccurate
|
|
17
18
|
- Reply on the pull request when it carries review comments, and scan that reply for the banned characters and internal phase labels before posting. The hook watches files and never sees a comment body on its way to the remote.
|
|
@@ -29,7 +30,6 @@ Without this skill, an edit made after a pull request is already open ships as a
|
|
|
29
30
|
|
|
30
31
|
- On `main`: stop and say to switch to a pull request branch
|
|
31
32
|
- Nothing changed: stop
|
|
32
|
-
- No upstream: stop and name both recoveries
|
|
33
33
|
- No open pull request: stop and route to the skill that opens one
|
|
34
34
|
|
|
35
35
|
## Out of scope
|
|
@@ -14,15 +14,16 @@ its own reply, so skip the comment in step 6. The push and body sync still run.
|
|
|
14
14
|
|
|
15
15
|
- If `git branch --show-current` returns `main`, stop: `❌ On main. Switch to a PR branch first.`
|
|
16
16
|
- If `git status --porcelain` is empty, stop: `❌ No changes to ship.`
|
|
17
|
-
- If `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null` is empty, stop: `❌ No upstream. Push the branch first or use git-ship.`
|
|
18
17
|
- If `gh pr view --json state -q '.state' 2>/dev/null` is not `OPEN`, stop: `❌ No open PR for this branch. Use git-ship to open one.`
|
|
19
18
|
|
|
19
|
+
A missing tracking ref is no longer a guard. An open pull request proves the branch reached the remote, and a branch created by worktree entry has been measured carrying an open pull request with no tracking ref, where the old guard reported that the branch still needed pushing. That message describes a state the branch is not in, so the run stopped on a diagnosis nobody could act on. Step 4 sets the ref instead.
|
|
20
|
+
|
|
20
21
|
## Sequence
|
|
21
22
|
|
|
22
23
|
1. Run `git status` to confirm the changes are intentional
|
|
23
24
|
2. Run `git add -A` to stage every change
|
|
24
25
|
3. Invoke `aitk:git-commit` to generate one conventional commit from the staged diff
|
|
25
|
-
4.
|
|
26
|
+
4. Push. When `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null` is empty, the branch has an open pull request and no tracking ref, so run `git push -u origin HEAD` to send the commit and set the ref in one step. Otherwise push to the tracking branch with `git push`, and when `git merge-base --is-ancestor @{u} HEAD` exits non-zero, a caller rewrote the branch and a plain push is rejected, so push with `--force-with-lease` instead. The lease is what stops the force from overwriting a commit this session never read. Run the ancestry test only where an upstream resolves, since it reads `@{u}`.
|
|
26
27
|
5. Check for existing review comments: `gh api 'repos/{owner}/{repo}/pulls/<number>/comments' --jq 'length'`, resolving `<number>` from `gh pr view --json number`.
|
|
27
28
|
6. When invoked with `reply-owned`, skip this step's comment: the caller posts the reply. Otherwise, if the count is above zero, the followup addresses review feedback: post a one-line summary of the fix with `gh pr comment --body`, first running the scan in `.claude/standards/publish.md` against it, or `${CLAUDE_SKILL_DIR}/../../standards/publish.md` when the project does not have it, since the hook does not see an inline comment body. If it is zero, run `gh pr view --json url,title,body` and update the body with `gh pr edit --body` when the new commit changes scope, and the title with `gh pr edit --title` when the scope shifted enough to make it inaccurate.
|
|
28
29
|
|
|
@@ -117,6 +117,29 @@ commits touching its own source path. The `covers` field names the domains a
|
|
|
117
117
|
target has actually stamped, so a domain that was never stamped is legible
|
|
118
118
|
rather than reading as a clean one.
|
|
119
119
|
|
|
120
|
+
### Tooling
|
|
121
|
+
|
|
122
|
+
Tooling records the stack chain an install resolved rather than per-file hashes,
|
|
123
|
+
since `src/tooling/` runs its own inject machinery and has no walk to attribute.
|
|
124
|
+
The chain is ordered nearest stack first, which is what a `--skip` run needs:
|
|
125
|
+
recording the leaf alone would send the next report measuring against a layer the
|
|
126
|
+
target deliberately does not carry. The report loads exactly those stacks, scans
|
|
127
|
+
them the way `aitk tooling sync` would, and counts what differs per category
|
|
128
|
+
under `tooling.counts`.
|
|
129
|
+
|
|
130
|
+
`measured` is the field the section exists for. A target carrying no chain
|
|
131
|
+
reports `measured: false`, which separates tooling nobody has ever looked at from
|
|
132
|
+
tooling that is current. Both produce zero changes otherwise. Every target
|
|
133
|
+
installed before the record shipped starts unmeasured and leaves on its next
|
|
134
|
+
`aitk tooling sync`, since backfilling would mean inferring the chain from
|
|
135
|
+
installed files, which is the guess the record replaces.
|
|
136
|
+
|
|
137
|
+
A workspace root records nothing, because each package resolves its own chain and
|
|
138
|
+
one written at the root would be that same guess. Run the check against a package
|
|
139
|
+
to measure it. Tooling never counts toward `--exit-code`, on the grounds seeds
|
|
140
|
+
are already excluded on, since it reports golden configs a project is expected to
|
|
141
|
+
edit and a job counting those stays red with no remedy.
|
|
142
|
+
|
|
120
143
|
### Surfaces reported beside the domains
|
|
121
144
|
|
|
122
145
|
Three sections sit outside the per-domain scan, because each names something
|
package/docs/ai-workflow.md
CHANGED
|
@@ -65,7 +65,7 @@ When features are independent, run them in parallel instead of sequentially. Use
|
|
|
65
65
|
|
|
66
66
|
- Create a worktree per feature, then start a Claude Code session in each
|
|
67
67
|
- Invoke `aitk:claude-feature` in each session. Plans land at the main worktree root as `.claude/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
|
|
68
|
-
- Implement, verify, and review each feature independently. `claude-review` and `claude-ui-test` write per-branch files at the main worktree root (`review-<
|
|
68
|
+
- Implement, verify, and review each feature independently. `claude-review` and `claude-ui-test` write per-branch files at the main worktree root (`review-<slug>.md`, `ui-checklist-<slug>.md`), so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
|
|
69
69
|
- Ship each worktree separately with `aitk:git-ship`
|
|
70
70
|
- For full autonomy per worktree, invoke `aitk:claude-autoship` instead of the manual chain. Approve the plan, walk away, come back to draft PRs.
|
|
71
71
|
|
package/docs/target-projects.md
CHANGED
|
@@ -100,7 +100,7 @@ When the toolkit updates, target projects pull changes per domain. There is one
|
|
|
100
100
|
|
|
101
101
|
`aitk sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next. A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `aitk:claude-seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is what `aitk:migration-standards` handles.
|
|
102
102
|
|
|
103
|
-
That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes
|
|
103
|
+
That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes. Standards, snippets, and governance record a hash per installed file. Tooling records the stack chain it resolved instead, since its install runs no per-file walk to attribute. Each domain holds its own toolkit commit, so syncing governance today does not move the revision standards measures against, and each domain reports the upstream commits touching its own source path. Running any sync stamps that domain, and the report names the ones still unstamped.
|
|
104
104
|
|
|
105
105
|
A project that has never synced under a toolkit new enough to write a stamp falls back to the toolkit's own git history. Installed content matching any version that history published proves the file untouched, so it reports `stale` naming the commit it came from, and content matching no published version stays `drifted`. That fallback needs the toolkit as a git checkout. Installed from the registry it ships source without history, and the report says attribution was unavailable rather than reading every file as a local edit.
|
|
106
106
|
|
|
@@ -108,9 +108,9 @@ Three further causes sit outside the per-domain scan, each naming something that
|
|
|
108
108
|
|
|
109
109
|
That last one matters most on an older project. Before it existed, a target holding `standards/` at its root reported zero entries for that domain, so a project that had never migrated was indistinguishable from one that was fully current.
|
|
110
110
|
|
|
111
|
-
Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code, and neither do superseded artifacts
|
|
111
|
+
Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code, and neither do superseded artifacts, seed drift, or tooling, since each reports content the project is expected to edit or place itself. An unmigrated domain does count, because running the relocation closes it.
|
|
112
112
|
|
|
113
|
-
Tooling
|
|
113
|
+
Tooling reports under a section of its own, and `measured` there says whether the target ever recorded a chain. One that never ran a tooling sync reports unmeasured rather than clean, which is what separates tooling nobody has looked at from tooling that is current. A workspace root records nothing either way, since each package resolves its own chain. Reconcile the configs with `aitk tooling sync <stack> <path>`.
|
|
114
114
|
|
|
115
115
|
### Catch-all
|
|
116
116
|
|
package/package.json
CHANGED
package/src/commands/sync.ts
CHANGED
|
@@ -3,9 +3,13 @@ import { join } from 'node:path'
|
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { cliPath, cliRun } from '@/cli-run'
|
|
5
5
|
import { PROJECT_ROOT } from '@/exec'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildCheckReport,
|
|
8
|
+
type CheckReport,
|
|
9
|
+
hasDrift,
|
|
10
|
+
SCANNED_DOMAINS,
|
|
11
|
+
} from '@/sync/check'
|
|
7
12
|
import { createGitRunner, createPullRequestOpener, hasGh } from '@/sync/git'
|
|
8
|
-
import { STAMP_DOMAINS } from '@/sync/stamp'
|
|
9
13
|
import {
|
|
10
14
|
detectDomains,
|
|
11
15
|
installedDomains,
|
|
@@ -133,6 +137,8 @@ function renderCheck(report: CheckReport): void {
|
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
renderTooling(report)
|
|
141
|
+
|
|
136
142
|
for (const entry of report.unmigrated) {
|
|
137
143
|
logStep(`${entry.domain} (not migrated)`)
|
|
138
144
|
logWarn(
|
|
@@ -157,17 +163,56 @@ function renderCheck(report: CheckReport): void {
|
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
outro()
|
|
166
|
+
// Scanned domains only. Tooling renders a section on every managed target, so
|
|
167
|
+
// naming it here repeats what that section already said under a second
|
|
168
|
+
// remedy, where a scanned domain nobody installed has no section at all and
|
|
169
|
+
// this line is the only place it appears.
|
|
160
170
|
const unmigrated = report.unmigrated.map((entry) => entry.domain)
|
|
161
|
-
const uncovered =
|
|
171
|
+
const uncovered = SCANNED_DOMAINS.filter(
|
|
162
172
|
(domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
|
|
163
173
|
)
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
if (uncovered.length === 0) return
|
|
175
|
+
|
|
166
176
|
process.stderr.write(
|
|
167
|
-
`${GREY}${
|
|
177
|
+
`${GREY}Unstamped: ${uncovered.join(', ')}. Run the matching sync to record one.${NC}\n`,
|
|
168
178
|
)
|
|
169
179
|
}
|
|
170
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Tooling prints whether it was measured before it prints any count, because a
|
|
183
|
+
* target with no chain recorded produces the same zero a current target does.
|
|
184
|
+
* Naming the state is the whole reason the section exists.
|
|
185
|
+
*/
|
|
186
|
+
function renderTooling(report: CheckReport): void {
|
|
187
|
+
const { tooling } = report
|
|
188
|
+
logStep('tooling')
|
|
189
|
+
|
|
190
|
+
if (!tooling.measured) {
|
|
191
|
+
logWarn(
|
|
192
|
+
tooling.chain.length === 0
|
|
193
|
+
? 'Not stamped. No chain recorded, so tooling drift is unmeasured.'
|
|
194
|
+
: `Recorded chain names no stack this toolkit ships: ${tooling.chain.join(' < ')}.`,
|
|
195
|
+
)
|
|
196
|
+
logInfo('Run `aitk tooling sync <stack>` to record what this target holds.')
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logInfo(`Chain: ${tooling.chain.join(' < ')}`)
|
|
201
|
+
if (tooling.commit !== undefined) {
|
|
202
|
+
logInfo(`Synced from ${tooling.commit} on ${tooling.syncedAt}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (tooling.changes === 0) {
|
|
206
|
+
logInfo('up to date')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const [category, count] of Object.entries(tooling.counts)) {
|
|
211
|
+
if (count > 0) logWarn(`${count} ${category}`)
|
|
212
|
+
}
|
|
213
|
+
logInfo('Run `aitk tooling sync` to reconcile these.')
|
|
214
|
+
}
|
|
215
|
+
|
|
171
216
|
/**
|
|
172
217
|
* Seeds print their own section because no sync command applies them. A `stale`
|
|
173
218
|
* seed is safe to take whole and a `drifted` one holds edits, which is the split
|
package/src/commands/tooling.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
stackExists,
|
|
19
19
|
} from '@/tooling/manifest'
|
|
20
20
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
21
|
+
import { recordToolingChain } from '@/tooling/stamp'
|
|
21
22
|
import { intro, logAdd, logInfo, logStep, logWarn, outro, select } from '@/ui'
|
|
22
23
|
|
|
23
24
|
const GREEN = '\x1b[0;32m'
|
|
@@ -219,6 +220,7 @@ async function runSync(
|
|
|
219
220
|
report(result, includeReferences)
|
|
220
221
|
|
|
221
222
|
if (result.totalChanges === 0) {
|
|
223
|
+
await stampChain(prepared.chain, prepared.target)
|
|
222
224
|
outro()
|
|
223
225
|
process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
|
|
224
226
|
return 0
|
|
@@ -255,11 +257,35 @@ async function runSync(
|
|
|
255
257
|
await applyReferences(prepared.chain, prepared.target, pending)
|
|
256
258
|
}
|
|
257
259
|
|
|
260
|
+
await stampChain(prepared.chain, prepared.target)
|
|
261
|
+
|
|
258
262
|
outro()
|
|
259
263
|
process.stderr.write(`${GREEN}✓ Tooling sync complete${NC}\n`)
|
|
260
264
|
return 0
|
|
261
265
|
}
|
|
262
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Writes the chain after the copies land, so a partial apply that throws leaves
|
|
269
|
+
* the previous record rather than a claim the target does not meet.
|
|
270
|
+
*/
|
|
271
|
+
async function stampChain(chain: Manifest[], target: string): Promise<void> {
|
|
272
|
+
const recorded = await recordToolingChain(
|
|
273
|
+
PROJECT_ROOT,
|
|
274
|
+
target,
|
|
275
|
+
chain,
|
|
276
|
+
new Date(),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if (recorded) {
|
|
280
|
+
logInfo(`Recorded chain: ${chain.map((entry) => entry.name).join(' < ')}`)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
logWarn(
|
|
285
|
+
'Workspace root: no chain recorded. Tooling reports unmeasured because the answer differs per package.',
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
263
289
|
async function runInject(
|
|
264
290
|
stack: string,
|
|
265
291
|
target: string,
|
|
@@ -290,6 +316,15 @@ async function runInject(
|
|
|
290
316
|
await injectGitignore(prepared.chain, prepared.target)
|
|
291
317
|
}
|
|
292
318
|
|
|
319
|
+
// Only a whole-stack inject records the chain. A flag-scoped run installs one
|
|
320
|
+
// category, and a chain recorded from it would send the report scanning for
|
|
321
|
+
// configs and deps the caller never asked to install. The claude stack is
|
|
322
|
+
// excluded here rather than in `prepare`, which is what keeps `aitk claude`
|
|
323
|
+
// able to drive injection while its stack stays out of the tooling record.
|
|
324
|
+
if (applyAll && !isStackExcluded(stack)) {
|
|
325
|
+
await stampChain(prepared.chain, prepared.target)
|
|
326
|
+
}
|
|
327
|
+
|
|
293
328
|
if (framed) outro()
|
|
294
329
|
return 0
|
|
295
330
|
}
|
package/src/sync/check.ts
CHANGED
|
@@ -13,32 +13,47 @@ import {
|
|
|
13
13
|
import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
|
|
14
14
|
import {
|
|
15
15
|
readStamp,
|
|
16
|
-
STAMP_DOMAINS,
|
|
17
16
|
type Stamp,
|
|
17
|
+
stampedChain,
|
|
18
18
|
stampedCommit,
|
|
19
19
|
type StampDomain,
|
|
20
20
|
} from '@/sync/stamp'
|
|
21
21
|
import { createStandardsAdapter } from '@/standards/adapter'
|
|
22
22
|
import { isDirectory } from '@/target'
|
|
23
|
+
import { loadManifest } from '@/tooling/manifest'
|
|
24
|
+
import { scan } from '@/tooling/scan'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Domains the sync engine walks file by file. Tooling is a stamp domain without
|
|
28
|
+
* being one of these, because `src/tooling/` never calls `planSync`, so the
|
|
29
|
+
* three lookups below have no entry to offer it.
|
|
30
|
+
*/
|
|
31
|
+
export const SCANNED_DOMAINS = [
|
|
32
|
+
'standards',
|
|
33
|
+
'snippets',
|
|
34
|
+
'governance',
|
|
35
|
+
] as const satisfies readonly StampDomain[]
|
|
36
|
+
|
|
37
|
+
export type ScannedDomain = (typeof SCANNED_DOMAINS)[number]
|
|
23
38
|
|
|
24
39
|
/**
|
|
25
40
|
* The toolkit path whose commits change what each domain holds. `claude/skills/`
|
|
26
41
|
* is deliberately absent: skills load live from the plugin directory, so they
|
|
27
42
|
* never go stale and belong in the read-only section instead.
|
|
28
43
|
*/
|
|
29
|
-
const SYNCED_SOURCES: Record<
|
|
44
|
+
const SYNCED_SOURCES: Record<ScannedDomain, string> = {
|
|
30
45
|
standards: 'standards/',
|
|
31
46
|
snippets: 'snippets/',
|
|
32
47
|
governance: 'governance/rules/',
|
|
33
48
|
}
|
|
34
49
|
|
|
35
|
-
const ADAPTERS: Record<
|
|
50
|
+
const ADAPTERS: Record<ScannedDomain, (root: string) => SyncAdapter> = {
|
|
36
51
|
standards: createStandardsAdapter,
|
|
37
52
|
snippets: createSnippetsAdapter,
|
|
38
53
|
governance: createGovAdapter,
|
|
39
54
|
}
|
|
40
55
|
|
|
41
|
-
const INSTALL_MARKERS: Record<
|
|
56
|
+
const INSTALL_MARKERS: Record<ScannedDomain, readonly string[]> = {
|
|
42
57
|
standards: ['.claude', 'standards'],
|
|
43
58
|
snippets: ['.claude', 'snippets'],
|
|
44
59
|
governance: ['.claude', 'rules'],
|
|
@@ -54,7 +69,7 @@ export interface StateCounts {
|
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
export interface DomainReport {
|
|
57
|
-
readonly domain:
|
|
72
|
+
readonly domain: ScannedDomain
|
|
58
73
|
readonly stamped: boolean
|
|
59
74
|
/** This domain's own anchor, not the target's most recent sync. */
|
|
60
75
|
readonly commit?: string
|
|
@@ -74,11 +89,60 @@ export interface UpstreamCommit {
|
|
|
74
89
|
readonly subject: string
|
|
75
90
|
}
|
|
76
91
|
|
|
92
|
+
/** Pending changes per category, from the same scan `aitk tooling sync` reads. */
|
|
93
|
+
export interface ToolingCounts {
|
|
94
|
+
readonly configs: number
|
|
95
|
+
readonly seeds: number
|
|
96
|
+
readonly scripts: number
|
|
97
|
+
readonly deps: number
|
|
98
|
+
readonly gitignore: number
|
|
99
|
+
readonly references: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tooling's own section. `measured` is the field the report exists for: without
|
|
104
|
+
* it a target that never installed tooling and a target whose tooling is current
|
|
105
|
+
* both render as zero changes, which is a claim rather than an absence of one.
|
|
106
|
+
*/
|
|
107
|
+
export interface ToolingReport {
|
|
108
|
+
readonly measured: boolean
|
|
109
|
+
/**
|
|
110
|
+
* Stack names the install resolved, nearest first. Carried even when the
|
|
111
|
+
* report is unmeasured, so a chain naming stacks this toolkit no longer ships
|
|
112
|
+
* stays distinguishable from a target that recorded none.
|
|
113
|
+
*/
|
|
114
|
+
readonly chain: readonly string[]
|
|
115
|
+
readonly commit?: string
|
|
116
|
+
readonly syncedAt?: string
|
|
117
|
+
readonly counts: ToolingCounts
|
|
118
|
+
readonly changes: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const UNMEASURED_TOOLING: ToolingReport = {
|
|
122
|
+
measured: false,
|
|
123
|
+
chain: [],
|
|
124
|
+
counts: {
|
|
125
|
+
configs: 0,
|
|
126
|
+
seeds: 0,
|
|
127
|
+
scripts: 0,
|
|
128
|
+
deps: 0,
|
|
129
|
+
gitignore: 0,
|
|
130
|
+
references: 0,
|
|
131
|
+
},
|
|
132
|
+
changes: 0,
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
export interface CheckReport {
|
|
78
136
|
readonly covers: readonly StampDomain[]
|
|
79
137
|
/** False when the target is not a toolkit project, so every section stays empty. */
|
|
80
138
|
readonly managed: boolean
|
|
81
139
|
readonly domains: readonly DomainReport[]
|
|
140
|
+
/**
|
|
141
|
+
* Reported beside the domains rather than as one of them, because tooling
|
|
142
|
+
* carries no per-file entries and no upstream range, so it fills almost none
|
|
143
|
+
* of `DomainReport`.
|
|
144
|
+
*/
|
|
145
|
+
readonly tooling: ToolingReport
|
|
82
146
|
/**
|
|
83
147
|
* Reported beside the domains rather than as one of them, because seeds carry
|
|
84
148
|
* no stamp and produce no change. See `@/sync/seeds-report`.
|
|
@@ -89,12 +153,58 @@ export interface CheckReport {
|
|
|
89
153
|
readonly newSkills: readonly string[]
|
|
90
154
|
}
|
|
91
155
|
|
|
92
|
-
export function installedStampDomains(target: string):
|
|
93
|
-
return
|
|
156
|
+
export function installedStampDomains(target: string): ScannedDomain[] {
|
|
157
|
+
return SCANNED_DOMAINS.filter((domain) =>
|
|
94
158
|
isDirectory(join(target, ...INSTALL_MARKERS[domain])),
|
|
95
159
|
)
|
|
96
160
|
}
|
|
97
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Reads the chain the install recorded and scans against those stacks rather
|
|
164
|
+
* than re-resolving from the leaf. A run that passed `--skip` installed fewer
|
|
165
|
+
* layers than the leaf's own chain reproduces, so re-resolving would report
|
|
166
|
+
* drift against a layer the target deliberately does not carry.
|
|
167
|
+
*
|
|
168
|
+
* A recorded stack the toolkit no longer ships resolves to nothing, and a chain
|
|
169
|
+
* where none resolve reads as unmeasured. Scanning the survivors would measure
|
|
170
|
+
* against a chain neither side agrees on.
|
|
171
|
+
*/
|
|
172
|
+
export function buildToolingReport(
|
|
173
|
+
toolkitRoot: string,
|
|
174
|
+
target: string,
|
|
175
|
+
stamp: Stamp | undefined,
|
|
176
|
+
): ToolingReport {
|
|
177
|
+
const chain = stampedChain(stamp)
|
|
178
|
+
const manifests = chain
|
|
179
|
+
.map((name) => loadManifest(toolkitRoot, name))
|
|
180
|
+
.filter((manifest) => manifest !== undefined)
|
|
181
|
+
|
|
182
|
+
if (manifests.length === 0) return { ...UNMEASURED_TOOLING, chain }
|
|
183
|
+
|
|
184
|
+
const result = scan(manifests, target, { includeReferences: true })
|
|
185
|
+
const record = stamp?.domains.tooling
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
measured: true,
|
|
189
|
+
chain,
|
|
190
|
+
commit: record?.commit,
|
|
191
|
+
syncedAt: record?.syncedAt,
|
|
192
|
+
counts: {
|
|
193
|
+
configs: result.configs.filter((entry) => entry.state !== 'matching')
|
|
194
|
+
.length,
|
|
195
|
+
seeds: result.seeds.filter((entry) => entry.state === 'missing').length,
|
|
196
|
+
scripts: result.scripts.filter((entry) => entry.state !== 'matching')
|
|
197
|
+
.length,
|
|
198
|
+
deps: result.deps.filter((entry) => entry.state === 'missing').length,
|
|
199
|
+
gitignore: result.gitignore.filter((entry) => entry.state === 'missing')
|
|
200
|
+
.length,
|
|
201
|
+
references: result.references.filter((entry) => entry.state === 'pending')
|
|
202
|
+
.length,
|
|
203
|
+
},
|
|
204
|
+
changes: result.totalChanges,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
98
208
|
/**
|
|
99
209
|
* Whether the target is a toolkit-managed project at all. Seeds are enumerated
|
|
100
210
|
* from the source rather than from what a target installed, so without this gate
|
|
@@ -143,6 +253,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
143
253
|
* the user can move content they wrote, so failing a job on it leaves the job
|
|
144
254
|
* red with no mechanical remedy. Seeds are excluded on the same grounds, since
|
|
145
255
|
* every seed a project edits would otherwise fail the check forever.
|
|
256
|
+
*
|
|
257
|
+
* Tooling is excluded on exactly the seeds grounds: a golden config is one the
|
|
258
|
+
* project is expected to edit, so a job counting it stays red with no remedy.
|
|
259
|
+
* Being unmeasured is not what excludes it, since an unmeasured report carries
|
|
260
|
+
* zero changes and would pass a count either way.
|
|
146
261
|
*/
|
|
147
262
|
export function hasDrift(report: CheckReport): boolean {
|
|
148
263
|
if (report.unmigrated.length > 0) return true
|
|
@@ -186,6 +301,7 @@ export async function buildCheckReport(
|
|
|
186
301
|
covers: [],
|
|
187
302
|
managed,
|
|
188
303
|
domains: [],
|
|
304
|
+
tooling: UNMEASURED_TOOLING,
|
|
189
305
|
seeds: { entries: [], historyUnavailable: false },
|
|
190
306
|
superseded: [],
|
|
191
307
|
unmigrated: [],
|
|
@@ -197,6 +313,7 @@ export async function buildCheckReport(
|
|
|
197
313
|
covers: stamp?.covers ?? [],
|
|
198
314
|
managed,
|
|
199
315
|
domains,
|
|
316
|
+
tooling: buildToolingReport(toolkitRoot, target, stamp),
|
|
200
317
|
seeds: buildSeedsReport(toolkitRoot, target),
|
|
201
318
|
superseded: collectSuperseded(target),
|
|
202
319
|
unmigrated,
|
|
@@ -208,7 +325,7 @@ async function buildDomainReport(
|
|
|
208
325
|
toolkitRoot: string,
|
|
209
326
|
target: string,
|
|
210
327
|
stamp: Stamp | undefined,
|
|
211
|
-
domain:
|
|
328
|
+
domain: ScannedDomain,
|
|
212
329
|
): Promise<DomainReport> {
|
|
213
330
|
const plan = planSync(ADAPTERS[domain](toolkitRoot), target)
|
|
214
331
|
const record = stamp?.domains[domain]
|
package/src/sync/stamp.ts
CHANGED
|
@@ -5,10 +5,16 @@ import { dirname, join, sep } from 'node:path'
|
|
|
5
5
|
import { execa } from 'execa'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Domains the stamp can
|
|
9
|
-
*
|
|
8
|
+
* Domains the stamp can record. The first three attribute file by file through
|
|
9
|
+
* the sync engine. Tooling runs its own inject and manifest machinery, so it
|
|
10
|
+
* records the stack chain it resolved instead and carries no file hashes.
|
|
10
11
|
*/
|
|
11
|
-
export const STAMP_DOMAINS = [
|
|
12
|
+
export const STAMP_DOMAINS = [
|
|
13
|
+
'standards',
|
|
14
|
+
'snippets',
|
|
15
|
+
'governance',
|
|
16
|
+
'tooling',
|
|
17
|
+
] as const
|
|
12
18
|
|
|
13
19
|
export type StampDomain = (typeof STAMP_DOMAINS)[number]
|
|
14
20
|
|
|
@@ -31,6 +37,13 @@ export interface DomainStamp {
|
|
|
31
37
|
readonly commit?: string
|
|
32
38
|
readonly syncedAt: string
|
|
33
39
|
readonly files: DomainHashes
|
|
40
|
+
/**
|
|
41
|
+
* Stack names the install resolved, nearest stack first. Present only for
|
|
42
|
+
* tooling. An ordered chain rather than the leaf name, because a stack that
|
|
43
|
+
* extends another cannot be reinstalled from its leaf alone, and a `--skip`
|
|
44
|
+
* run installs fewer layers than the leaf's own chain would reproduce.
|
|
45
|
+
*/
|
|
46
|
+
readonly chain?: readonly string[]
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
export interface Stamp {
|
|
@@ -91,6 +104,15 @@ export function stampedHashes(
|
|
|
91
104
|
return stamp.domains[domain]?.files ?? {}
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The stack chain tooling last installed. An empty result is the state every
|
|
109
|
+
* target predating the tooling record sits in, and the report reads it as
|
|
110
|
+
* unmeasured rather than as clean.
|
|
111
|
+
*/
|
|
112
|
+
export function stampedChain(stamp: Stamp | undefined): readonly string[] {
|
|
113
|
+
return stamp?.domains.tooling?.chain ?? []
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
/**
|
|
95
117
|
* Replaces one domain's record and leaves the others untouched, because domains
|
|
96
118
|
* install and sync independently but share the one file.
|
|
@@ -100,6 +122,34 @@ export async function writeStamp(
|
|
|
100
122
|
source: StampSource,
|
|
101
123
|
hashes: DomainHashes,
|
|
102
124
|
now: Date,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await putDomain(target, source, { files: sortKeys(hashes) }, now)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Records what tooling installed. `files` stays empty because `src/tooling/`
|
|
131
|
+
* never runs the sync engine, so there is no per-file attribution to store and
|
|
132
|
+
* the chain is the whole record.
|
|
133
|
+
*/
|
|
134
|
+
export async function writeChainStamp(
|
|
135
|
+
target: string,
|
|
136
|
+
toolkitRoot: string,
|
|
137
|
+
chain: readonly string[],
|
|
138
|
+
now: Date,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
await putDomain(
|
|
141
|
+
target,
|
|
142
|
+
{ domain: 'tooling', toolkitRoot },
|
|
143
|
+
{ files: {}, chain: [...chain] },
|
|
144
|
+
now,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function putDomain(
|
|
149
|
+
target: string,
|
|
150
|
+
source: StampSource,
|
|
151
|
+
payload: Pick<DomainStamp, 'files' | 'chain'>,
|
|
152
|
+
now: Date,
|
|
103
153
|
): Promise<void> {
|
|
104
154
|
const previous = readStamp(target)
|
|
105
155
|
const commit = await toolkitCommit(source.toolkitRoot)
|
|
@@ -107,7 +157,7 @@ export async function writeStamp(
|
|
|
107
157
|
const record: DomainStamp = {
|
|
108
158
|
...(commit === undefined ? {} : { commit }),
|
|
109
159
|
syncedAt: now.toISOString(),
|
|
110
|
-
|
|
160
|
+
...payload,
|
|
111
161
|
}
|
|
112
162
|
|
|
113
163
|
const domains = sortDomains({
|
|
@@ -187,16 +237,26 @@ function isStamp(value: unknown): value is Stamp {
|
|
|
187
237
|
)
|
|
188
238
|
}
|
|
189
239
|
|
|
240
|
+
/**
|
|
241
|
+
* `chain` is optional, which is what keeps a stamp written before tooling
|
|
242
|
+
* joined the domains readable rather than parsing as corrupt and discarding
|
|
243
|
+
* the three records it does carry.
|
|
244
|
+
*/
|
|
190
245
|
function isDomainStamp(value: unknown): value is DomainStamp {
|
|
191
246
|
if (!isRecord(value) || !isRecord(value.files)) return false
|
|
192
247
|
if (typeof value.syncedAt !== 'string') return false
|
|
193
248
|
if (value.commit !== undefined && typeof value.commit !== 'string') {
|
|
194
249
|
return false
|
|
195
250
|
}
|
|
251
|
+
if (value.chain !== undefined && !isStringArray(value.chain)) return false
|
|
196
252
|
|
|
197
253
|
return Object.values(value.files).every((hash) => typeof hash === 'string')
|
|
198
254
|
}
|
|
199
255
|
|
|
256
|
+
function isStringArray(value: unknown): value is string[] {
|
|
257
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
258
|
+
}
|
|
259
|
+
|
|
200
260
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
201
261
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
202
262
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { writeChainStamp } from '@/sync/stamp'
|
|
4
|
+
import type { Manifest } from '@/tooling/manifest'
|
|
5
|
+
import { readPackage } from '@/tooling/package'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether the target is a workspace root, where every package resolves its own
|
|
9
|
+
* stack. `package.json` covers bun, npm, and yarn, and pnpm declares the same
|
|
10
|
+
* fact in its own file instead.
|
|
11
|
+
*/
|
|
12
|
+
export function isWorkspaceRoot(target: string): boolean {
|
|
13
|
+
if (existsSync(join(target, 'pnpm-workspace.yaml'))) return true
|
|
14
|
+
|
|
15
|
+
const workspaces = readPackage(join(target, 'package.json'))?.workspaces
|
|
16
|
+
return Array.isArray(workspaces) || isRecord(workspaces)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Records the chain an install resolved, and returns whether it wrote one. A
|
|
21
|
+
* workspace root is the single refusal: one chain recorded there would be a
|
|
22
|
+
* guess at what its packages hold, and the report reading tooling as unmeasured
|
|
23
|
+
* is the true answer rather than a clean one.
|
|
24
|
+
*/
|
|
25
|
+
export async function recordToolingChain(
|
|
26
|
+
toolkitRoot: string,
|
|
27
|
+
target: string,
|
|
28
|
+
chain: readonly Manifest[],
|
|
29
|
+
now: Date,
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
if (isWorkspaceRoot(target)) return false
|
|
32
|
+
|
|
33
|
+
await writeChainStamp(
|
|
34
|
+
target,
|
|
35
|
+
toolkitRoot,
|
|
36
|
+
chain.map((manifest) => manifest.name),
|
|
37
|
+
now,
|
|
38
|
+
)
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
44
|
+
}
|
package/standards/slug.md
CHANGED
|
@@ -16,9 +16,11 @@ Does not govern:
|
|
|
16
16
|
|
|
17
17
|
## The transform
|
|
18
18
|
|
|
19
|
-
Run `git branch --show-current`
|
|
19
|
+
Run `git branch --show-current`. Drop a leading segment naming one of the types `branch.md` defines when a further segment follows it, then replace every remaining `/` with `-`. The result is `<slug>`. Anything reading a branch-derived name uses this transform, so two surfaces cannot spell it differently.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The type comes off because a plan takes its slug from the concern it covers and the branch executing that plan carries the same slug behind a type prefix. Keeping the prefix sends every surface that finds a plan from a branch name looking for `feat-jwt-expiration`, which no plan is filed under. A first segment matching no type stays, so `spike/parser` still yields `spike-parser`.
|
|
22
|
+
|
|
23
|
+
A surface that persists output to a shared folder carries the slug in the filename, which is what keeps parallel worktrees from overwriting each other's output. Two branches differing only in type collapse onto one slug. That collision reaches the worktree directory as well, since it takes its name from the same string, so the branch is not where it would first surface.
|
|
22
24
|
|
|
23
25
|
## An empty result
|
|
24
26
|
|