@ikon85/agent-workflow-kit 0.11.0 → 0.13.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/.agents/skills/kit-update/SKILL.md +52 -0
- package/.agents/skills/setup-workflow/SKILL.md +35 -0
- package/.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml +31 -0
- package/.agents/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/skills/kit-update/SKILL.md +52 -0
- package/.claude/skills/setup-workflow/SKILL.md +35 -0
- package/.claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml +31 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/skills/skill-manifest.json +10 -0
- package/README.md +15 -0
- package/agent-workflow-kit.package.json +48 -5
- package/package.json +3 -2
- package/scripts/build-kit.test.mjs +25 -0
- package/scripts/kit-update-pr.mjs +115 -0
- package/scripts/kit-update-pr.test.mjs +99 -0
- package/scripts/test_skill_setup_workflow_seeds.py +65 -0
- package/src/cli.mjs +29 -4
- package/src/commands/update.mjs +110 -76
- package/src/lib/bundle.mjs +3 -0
- package/src/lib/updateCandidate.mjs +101 -0
- package/src/lib/updateReconcile.mjs +89 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kit-update
|
|
3
|
+
description: "Preview and transactionally apply a parity-verified agent-workflow-kit release without overwriting local modifications or auto-resolving conflicts."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Kit Update
|
|
7
|
+
|
|
8
|
+
Update an installed consumer only from the public scoped package
|
|
9
|
+
`@ikon85/agent-workflow-kit`. The unscoped `agent-workflow-kit` package belongs
|
|
10
|
+
to another publisher and is never a valid update source. Existing installs from
|
|
11
|
+
`npx github:iKon85/agent-workflow-kit` remain valid, but updates must use the
|
|
12
|
+
scoped npm release so the shared release-parity checker can prove that npm and
|
|
13
|
+
the matching GitHub release contain the same artifact.
|
|
14
|
+
|
|
15
|
+
## Workflow
|
|
16
|
+
|
|
17
|
+
1. Preview the delta without writing:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx @ikon85/agent-workflow-kit@latest diff
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Review the named added, updated, locally modified, removed, and conflicting
|
|
24
|
+
paths. Do not reinterpret a local modification as permission to overwrite it.
|
|
25
|
+
|
|
26
|
+
2. Apply the update:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npx @ikon85/agent-workflow-kit@latest update
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The command checks npm/GitHub release parity before staging. It prepares a
|
|
33
|
+
complete candidate outside the consumer, runs the consumer's existing
|
|
34
|
+
`npm test` command there, and activates only a verified candidate. A staging
|
|
35
|
+
or verification failure leaves the installed tree byte-identical.
|
|
36
|
+
|
|
37
|
+
3. Read the terminal report. `aktuell` proves a second run found no upstream
|
|
38
|
+
delta. A conflict report names and counts every category and leaves every
|
|
39
|
+
consumer file untouched. Follow its recommendation and resolve each named
|
|
40
|
+
conflict manually; never auto-merge, delete a local edit, or silently choose
|
|
41
|
+
the incoming copy.
|
|
42
|
+
|
|
43
|
+
4. If a candidate is interrupted, discard its reported stage directory or
|
|
44
|
+
resume the transaction through the update API's `resumeFrom` option. Do not
|
|
45
|
+
copy staged files into the consumer by hand.
|
|
46
|
+
|
|
47
|
+
## State contract
|
|
48
|
+
|
|
49
|
+
The update API reports `checking -> preview/awaiting_decision -> staging ->
|
|
50
|
+
verifying -> applied | conflicted | failed | aborted`. `conflicted`, `failed`,
|
|
51
|
+
and `aborted` never authorize partial activation. The existing manifest,
|
|
52
|
+
three-way diff, and atomic-write seams remain the source of truth.
|
|
@@ -24,6 +24,7 @@ This is a prompt-driven skill, not a deterministic script. Explore, present what
|
|
|
24
24
|
| `docs/agents/code-review.md` | Standards-source pointers + adjacent-review-tooling notes the `code-review` skill's Standards axis reads (Section I) |
|
|
25
25
|
| `## Workflow` in CLAUDE.md **and** AGENTS.md | generic entry-point map seeded from [workflow-overview.md](./workflow-overview.md) (Section F + Write) |
|
|
26
26
|
| `## Agent skills` + `## Prod` in CLAUDE.md **and** AGENTS.md | one-line pointers + deploy target (Sections C/G + Write) |
|
|
27
|
+
| `.github/workflows/agent-workflow-kit-update.yml` | optional tested Kit update pull request for GitHub consumers (Section A2) |
|
|
27
28
|
|
|
28
29
|
## Idempotency contract — read before writing anything
|
|
29
30
|
|
|
@@ -68,6 +69,40 @@ Default posture: these skills were designed for GitHub. If a `git remote` points
|
|
|
68
69
|
|
|
69
70
|
Seed `docs/agents/issue-tracker.md` from the matching template in this folder: [issue-tracker-github.md](./issue-tracker-github.md), [issue-tracker-gitlab.md](./issue-tracker-gitlab.md), [issue-tracker-local.md](./issue-tracker-local.md). For "other", write it from the user's description.
|
|
70
71
|
|
|
72
|
+
### 2a. Automatic Kit update pull requests (GitHub tracker only)
|
|
73
|
+
|
|
74
|
+
> A short scheduled check can keep the installed Kit current by opening one normal pull request after the candidate passes the consumer's own tests. It never merges the pull request for you.
|
|
75
|
+
|
|
76
|
+
Only after the user has confirmed a **GitHub tracker**, ask in plain language: *"Should GitHub check weekly for a tested Agent Workflow Kit update and keep one update pull request ready?"* Offer these explicit choices:
|
|
77
|
+
|
|
78
|
+
Before offering **Enable**, confirm the consumer has a committed `package-lock.json` and a usable `package.json` `npm test` command. The shipped workflow installs the locked dependencies with `npm ci --ignore-scripts`; without that lockfile/test contract it cannot prove the candidate in a clean checkout, so explain the prerequisite and do not create the workflow yet.
|
|
79
|
+
|
|
80
|
+
Also read the repository Actions policy before enabling:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
gh api repos/<owner>/<repo>/actions/permissions/workflow
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The response must report `"can_approve_pull_request_reviews": true`; this is GitHub's **Allow GitHub Actions to create and approve pull requests** gate used by the stable PR upsert. If it is false, or the policy cannot be read, do not seed the workflow yet. Guide the user to **Settings → Actions → General → Workflow permissions**, enable that checkbox, and rerun `setup-workflow`.
|
|
87
|
+
|
|
88
|
+
If the user prefers `gh`, first show the exact mutation and obtain **explicit confirmation**; never change repository settings merely because setup was invoked. After confirmation, preserve the currently reported `default_workflow_permissions` value (`read` or `write`) and set the gate with:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
gh api --method PUT repos/<owner>/<repo>/actions/permissions/workflow \
|
|
92
|
+
-f default_workflow_permissions=<current-read-or-write> \
|
|
93
|
+
-F can_approve_pull_request_reviews=true
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Read the policy back and offer **Enable** only after the field is actually true.
|
|
97
|
+
|
|
98
|
+
- **Enable** — seed `.github/workflows/agent-workflow-kit-update.yml` from [assets/agent-workflow-kit-update.yml](./assets/agent-workflow-kit-update.yml).
|
|
99
|
+
- **Opt out** — do not create a GitHub workflow, branch, or pull request.
|
|
100
|
+
- **Ask later** — do not create a GitHub workflow, branch, or pull request; explain that re-running `setup-workflow` can offer it again.
|
|
101
|
+
|
|
102
|
+
This workflow opt-in has its own file-level idempotency rule: if the destination is already present, leave it byte-for-byte unchanged and report `skipped (already present)`. On repeated setup runs, do not prompt again when it exists. The workflow consumes the scoped `@ikon85/agent-workflow-kit` release and its existing parity-checked transactional update command; do not reproduce either mechanism in setup prose or shell steps.
|
|
103
|
+
|
|
104
|
+
For GitLab, local, other, or an unknown provider, give only a provider-neutral explanation that automatic updates require provider-specific CI and pull-request support; **do not create a GitHub workflow**. Setup itself never creates the update branch or pull request — only an opted-in workflow run may do that later.
|
|
105
|
+
|
|
71
106
|
**Conditional board-write note (only when the GitHub tracker uses a managed board):** if Section D ends with `board-sync.md` at `mode: github-projects-v2`, add one line to `issue-tracker.md`: *"Board writes (item-add, status/wave/cluster field edits, sub-issue links) go through the board-sync helper, not bare `gh issue create`/`gh project item-*`."* Do **not** add this for GitLab, local, other, or `mode: none`.
|
|
72
107
|
|
|
73
108
|
### 3. Section B — Triage labels
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Agent Workflow Kit update
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: '17 6 * * 1'
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
pull-requests: write
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: agent-workflow-kit-update
|
|
14
|
+
cancel-in-progress: false
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
update:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
with:
|
|
22
|
+
fetch-depth: 0
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: 22.14
|
|
26
|
+
- name: Install locked consumer dependencies
|
|
27
|
+
run: npm ci --ignore-scripts
|
|
28
|
+
- name: Verify, test, and upsert the Kit update pull request
|
|
29
|
+
env:
|
|
30
|
+
GH_TOKEN: ${{ github.token }}
|
|
31
|
+
run: npx --yes --package=@ikon85/agent-workflow-kit@latest agent-workflow-kit-update-pr
|
|
@@ -19,6 +19,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
|
|
|
19
19
|
- **Implementation slice:** use `tdd` for one behavior at a time: RED, GREEN, then refactor.
|
|
20
20
|
- **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
|
|
21
21
|
- **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
|
|
22
|
+
- **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
|
|
22
23
|
- **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
|
|
23
24
|
|
|
24
25
|
## Routing Rule
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kit-update
|
|
3
|
+
description: "Preview and transactionally apply a parity-verified agent-workflow-kit release without overwriting local modifications or auto-resolving conflicts."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Kit Update
|
|
7
|
+
|
|
8
|
+
Update an installed consumer only from the public scoped package
|
|
9
|
+
`@ikon85/agent-workflow-kit`. The unscoped `agent-workflow-kit` package belongs
|
|
10
|
+
to another publisher and is never a valid update source. Existing installs from
|
|
11
|
+
`npx github:iKon85/agent-workflow-kit` remain valid, but updates must use the
|
|
12
|
+
scoped npm release so the shared release-parity checker can prove that npm and
|
|
13
|
+
the matching GitHub release contain the same artifact.
|
|
14
|
+
|
|
15
|
+
## Workflow
|
|
16
|
+
|
|
17
|
+
1. Preview the delta without writing:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx @ikon85/agent-workflow-kit@latest diff
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Review the named added, updated, locally modified, removed, and conflicting
|
|
24
|
+
paths. Do not reinterpret a local modification as permission to overwrite it.
|
|
25
|
+
|
|
26
|
+
2. Apply the update:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npx @ikon85/agent-workflow-kit@latest update
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The command checks npm/GitHub release parity before staging. It prepares a
|
|
33
|
+
complete candidate outside the consumer, runs the consumer's existing
|
|
34
|
+
`npm test` command there, and activates only a verified candidate. A staging
|
|
35
|
+
or verification failure leaves the installed tree byte-identical.
|
|
36
|
+
|
|
37
|
+
3. Read the terminal report. `aktuell` proves a second run found no upstream
|
|
38
|
+
delta. A conflict report names and counts every category and leaves every
|
|
39
|
+
consumer file untouched. Follow its recommendation and resolve each named
|
|
40
|
+
conflict manually; never auto-merge, delete a local edit, or silently choose
|
|
41
|
+
the incoming copy.
|
|
42
|
+
|
|
43
|
+
4. If a candidate is interrupted, discard its reported stage directory or
|
|
44
|
+
resume the transaction through the update API's `resumeFrom` option. Do not
|
|
45
|
+
copy staged files into the consumer by hand.
|
|
46
|
+
|
|
47
|
+
## State contract
|
|
48
|
+
|
|
49
|
+
The update API reports `checking -> preview/awaiting_decision -> staging ->
|
|
50
|
+
verifying -> applied | conflicted | failed | aborted`. `conflicted`, `failed`,
|
|
51
|
+
and `aborted` never authorize partial activation. The existing manifest,
|
|
52
|
+
three-way diff, and atomic-write seams remain the source of truth.
|
|
@@ -24,6 +24,7 @@ This is a prompt-driven skill, not a deterministic script. Explore, present what
|
|
|
24
24
|
| `docs/agents/code-review.md` | Standards-source pointers + adjacent-review-tooling notes the `code-review` skill's Standards axis reads (Section I) |
|
|
25
25
|
| `## Workflow` in CLAUDE.md **and** AGENTS.md | generic entry-point map seeded from [workflow-overview.md](./workflow-overview.md) (Section F + Write) |
|
|
26
26
|
| `## Agent skills` + `## Prod` in CLAUDE.md **and** AGENTS.md | one-line pointers + deploy target (Sections C/G + Write) |
|
|
27
|
+
| `.github/workflows/agent-workflow-kit-update.yml` | optional tested Kit update pull request for GitHub consumers (Section A2) |
|
|
27
28
|
|
|
28
29
|
## Idempotency contract — read before writing anything
|
|
29
30
|
|
|
@@ -68,6 +69,40 @@ Default posture: these skills were designed for GitHub. If a `git remote` points
|
|
|
68
69
|
|
|
69
70
|
Seed `docs/agents/issue-tracker.md` from the matching template in this folder: [issue-tracker-github.md](./issue-tracker-github.md), [issue-tracker-gitlab.md](./issue-tracker-gitlab.md), [issue-tracker-local.md](./issue-tracker-local.md). For "other", write it from the user's description.
|
|
70
71
|
|
|
72
|
+
### 2a. Automatic Kit update pull requests (GitHub tracker only)
|
|
73
|
+
|
|
74
|
+
> A short scheduled check can keep the installed Kit current by opening one normal pull request after the candidate passes the consumer's own tests. It never merges the pull request for you.
|
|
75
|
+
|
|
76
|
+
Only after the user has confirmed a **GitHub tracker**, ask in plain language: *"Should GitHub check weekly for a tested Agent Workflow Kit update and keep one update pull request ready?"* Offer these explicit choices:
|
|
77
|
+
|
|
78
|
+
Before offering **Enable**, confirm the consumer has a committed `package-lock.json` and a usable `package.json` `npm test` command. The shipped workflow installs the locked dependencies with `npm ci --ignore-scripts`; without that lockfile/test contract it cannot prove the candidate in a clean checkout, so explain the prerequisite and do not create the workflow yet.
|
|
79
|
+
|
|
80
|
+
Also read the repository Actions policy before enabling:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
gh api repos/<owner>/<repo>/actions/permissions/workflow
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The response must report `"can_approve_pull_request_reviews": true`; this is GitHub's **Allow GitHub Actions to create and approve pull requests** gate used by the stable PR upsert. If it is false, or the policy cannot be read, do not seed the workflow yet. Guide the user to **Settings → Actions → General → Workflow permissions**, enable that checkbox, and rerun `setup-workflow`.
|
|
87
|
+
|
|
88
|
+
If the user prefers `gh`, first show the exact mutation and obtain **explicit confirmation**; never change repository settings merely because setup was invoked. After confirmation, preserve the currently reported `default_workflow_permissions` value (`read` or `write`) and set the gate with:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
gh api --method PUT repos/<owner>/<repo>/actions/permissions/workflow \
|
|
92
|
+
-f default_workflow_permissions=<current-read-or-write> \
|
|
93
|
+
-F can_approve_pull_request_reviews=true
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Read the policy back and offer **Enable** only after the field is actually true.
|
|
97
|
+
|
|
98
|
+
- **Enable** — seed `.github/workflows/agent-workflow-kit-update.yml` from [assets/agent-workflow-kit-update.yml](./assets/agent-workflow-kit-update.yml).
|
|
99
|
+
- **Opt out** — do not create a GitHub workflow, branch, or pull request.
|
|
100
|
+
- **Ask later** — do not create a GitHub workflow, branch, or pull request; explain that re-running `setup-workflow` can offer it again.
|
|
101
|
+
|
|
102
|
+
This workflow opt-in has its own file-level idempotency rule: if the destination is already present, leave it byte-for-byte unchanged and report `skipped (already present)`. On repeated setup runs, do not prompt again when it exists. The workflow consumes the scoped `@ikon85/agent-workflow-kit` release and its existing parity-checked transactional update command; do not reproduce either mechanism in setup prose or shell steps.
|
|
103
|
+
|
|
104
|
+
For GitLab, local, other, or an unknown provider, give only a provider-neutral explanation that automatic updates require provider-specific CI and pull-request support; **do not create a GitHub workflow**. Setup itself never creates the update branch or pull request — only an opted-in workflow run may do that later.
|
|
105
|
+
|
|
71
106
|
**Conditional board-write note (only when the GitHub tracker uses a managed board):** if Section D ends with `board-sync.md` at `mode: github-projects-v2`, add one line to `issue-tracker.md`: *"Board writes (item-add, status/wave/cluster field edits, sub-issue links) go through the board-sync helper, not bare `gh issue create`/`gh project item-*`."* Do **not** add this for GitLab, local, other, or `mode: none`.
|
|
72
107
|
|
|
73
108
|
### 3. Section B — Triage labels
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Agent Workflow Kit update
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: '17 6 * * 1'
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
pull-requests: write
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: agent-workflow-kit-update
|
|
14
|
+
cancel-in-progress: false
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
update:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
with:
|
|
22
|
+
fetch-depth: 0
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: 22.14
|
|
26
|
+
- name: Install locked consumer dependencies
|
|
27
|
+
run: npm ci --ignore-scripts
|
|
28
|
+
- name: Verify, test, and upsert the Kit update pull request
|
|
29
|
+
env:
|
|
30
|
+
GH_TOKEN: ${{ github.token }}
|
|
31
|
+
run: npx --yes --package=@ikon85/agent-workflow-kit@latest agent-workflow-kit-update-pr
|
|
@@ -19,6 +19,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
|
|
|
19
19
|
- **Implementation slice:** use `tdd` for one behavior at a time: RED, GREEN, then refactor.
|
|
20
20
|
- **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
|
|
21
21
|
- **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
|
|
22
|
+
- **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
|
|
22
23
|
- **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
|
|
23
24
|
|
|
24
25
|
## Routing Rule
|
package/README.md
CHANGED
|
@@ -332,6 +332,21 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
|
|
|
332
332
|
|
|
333
333
|
## Release notes
|
|
334
334
|
|
|
335
|
+
### 0.13.0
|
|
336
|
+
|
|
337
|
+
- added: `.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml`
|
|
338
|
+
- added: `.claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml`
|
|
339
|
+
- added: `scripts/kit-update-pr.mjs`
|
|
340
|
+
- changed: `.agents/skills/setup-workflow/SKILL.md`
|
|
341
|
+
- changed: `.claude/skills/setup-workflow/SKILL.md`
|
|
342
|
+
|
|
343
|
+
### 0.12.0
|
|
344
|
+
|
|
345
|
+
- added: `.agents/skills/kit-update/SKILL.md`
|
|
346
|
+
- added: `.claude/skills/kit-update/SKILL.md`
|
|
347
|
+
- changed: `.agents/skills/setup-workflow/workflow-overview.md`
|
|
348
|
+
- changed: `.claude/skills/setup-workflow/workflow-overview.md`
|
|
349
|
+
|
|
335
350
|
### 0.11.0
|
|
336
351
|
|
|
337
352
|
- added: `scripts/release-parity.mjs`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kitVersion": "0.
|
|
2
|
+
"kitVersion": "0.13.0",
|
|
3
3
|
"files": [
|
|
4
4
|
{
|
|
5
5
|
"path": ".agents/skills/ask-matt/SKILL.md",
|
|
@@ -325,6 +325,15 @@
|
|
|
325
325
|
"mode": 420,
|
|
326
326
|
"origin": "kit"
|
|
327
327
|
},
|
|
328
|
+
{
|
|
329
|
+
"path": ".agents/skills/kit-update/SKILL.md",
|
|
330
|
+
"kind": "skill",
|
|
331
|
+
"ownerSkill": "kit-update",
|
|
332
|
+
"surface": "codex",
|
|
333
|
+
"sha256": "26737ea49ac104260e56fd1604f60249ca1d59f1a2c1f22db55649833d01ebc3",
|
|
334
|
+
"mode": 420,
|
|
335
|
+
"origin": "kit"
|
|
336
|
+
},
|
|
328
337
|
{
|
|
329
338
|
"path": ".agents/skills/local-ci/SKILL.md",
|
|
330
339
|
"kind": "skill",
|
|
@@ -451,6 +460,15 @@
|
|
|
451
460
|
"mode": 420,
|
|
452
461
|
"origin": "kit"
|
|
453
462
|
},
|
|
463
|
+
{
|
|
464
|
+
"path": ".agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml",
|
|
465
|
+
"kind": "skill",
|
|
466
|
+
"ownerSkill": "setup-workflow",
|
|
467
|
+
"surface": "codex",
|
|
468
|
+
"sha256": "d8abf995d67184b4459b9565c9062319fc42e8a685042b288b3066a07a6fda59",
|
|
469
|
+
"mode": 420,
|
|
470
|
+
"origin": "kit"
|
|
471
|
+
},
|
|
454
472
|
{
|
|
455
473
|
"path": ".agents/skills/setup-workflow/board-sync.md",
|
|
456
474
|
"kind": "skill",
|
|
@@ -519,7 +537,7 @@
|
|
|
519
537
|
"kind": "skill",
|
|
520
538
|
"ownerSkill": "setup-workflow",
|
|
521
539
|
"surface": "codex",
|
|
522
|
-
"sha256": "
|
|
540
|
+
"sha256": "efc7818d98158a85a79f3a652787d2e557552fa5175b32b24916c894388b9b74",
|
|
523
541
|
"mode": 420,
|
|
524
542
|
"origin": "kit"
|
|
525
543
|
},
|
|
@@ -564,7 +582,7 @@
|
|
|
564
582
|
"kind": "skill",
|
|
565
583
|
"ownerSkill": "setup-workflow",
|
|
566
584
|
"surface": "codex",
|
|
567
|
-
"sha256": "
|
|
585
|
+
"sha256": "6ce3954e4f94d112801cba0d145e4d3b6f101aca5e50e6b1f9794aa0288881a9",
|
|
568
586
|
"mode": 420,
|
|
569
587
|
"origin": "kit"
|
|
570
588
|
},
|
|
@@ -1271,6 +1289,15 @@
|
|
|
1271
1289
|
"mode": 420,
|
|
1272
1290
|
"origin": "kit"
|
|
1273
1291
|
},
|
|
1292
|
+
{
|
|
1293
|
+
"path": ".claude/skills/kit-update/SKILL.md",
|
|
1294
|
+
"kind": "skill",
|
|
1295
|
+
"ownerSkill": "kit-update",
|
|
1296
|
+
"surface": "claude",
|
|
1297
|
+
"sha256": "26737ea49ac104260e56fd1604f60249ca1d59f1a2c1f22db55649833d01ebc3",
|
|
1298
|
+
"mode": 420,
|
|
1299
|
+
"origin": "kit"
|
|
1300
|
+
},
|
|
1274
1301
|
{
|
|
1275
1302
|
"path": ".claude/skills/local-ci/SKILL.md",
|
|
1276
1303
|
"kind": "skill",
|
|
@@ -1415,6 +1442,15 @@
|
|
|
1415
1442
|
"mode": 420,
|
|
1416
1443
|
"origin": "kit"
|
|
1417
1444
|
},
|
|
1445
|
+
{
|
|
1446
|
+
"path": ".claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml",
|
|
1447
|
+
"kind": "skill",
|
|
1448
|
+
"ownerSkill": "setup-workflow",
|
|
1449
|
+
"surface": "claude",
|
|
1450
|
+
"sha256": "d8abf995d67184b4459b9565c9062319fc42e8a685042b288b3066a07a6fda59",
|
|
1451
|
+
"mode": 420,
|
|
1452
|
+
"origin": "kit"
|
|
1453
|
+
},
|
|
1418
1454
|
{
|
|
1419
1455
|
"path": ".claude/skills/setup-workflow/board-sync.md",
|
|
1420
1456
|
"kind": "skill",
|
|
@@ -1483,7 +1519,7 @@
|
|
|
1483
1519
|
"kind": "skill",
|
|
1484
1520
|
"ownerSkill": "setup-workflow",
|
|
1485
1521
|
"surface": "claude",
|
|
1486
|
-
"sha256": "
|
|
1522
|
+
"sha256": "efc7818d98158a85a79f3a652787d2e557552fa5175b32b24916c894388b9b74",
|
|
1487
1523
|
"mode": 420,
|
|
1488
1524
|
"origin": "kit"
|
|
1489
1525
|
},
|
|
@@ -1528,7 +1564,7 @@
|
|
|
1528
1564
|
"kind": "skill",
|
|
1529
1565
|
"ownerSkill": "setup-workflow",
|
|
1530
1566
|
"surface": "claude",
|
|
1531
|
-
"sha256": "
|
|
1567
|
+
"sha256": "6ce3954e4f94d112801cba0d145e4d3b6f101aca5e50e6b1f9794aa0288881a9",
|
|
1532
1568
|
"mode": 420,
|
|
1533
1569
|
"origin": "kit"
|
|
1534
1570
|
},
|
|
@@ -1865,6 +1901,13 @@
|
|
|
1865
1901
|
"mode": 420,
|
|
1866
1902
|
"origin": "kit"
|
|
1867
1903
|
},
|
|
1904
|
+
{
|
|
1905
|
+
"path": "scripts/kit-update-pr.mjs",
|
|
1906
|
+
"kind": "script",
|
|
1907
|
+
"sha256": "64e7818003d7c6db227eadad0b114e199399a52a96965ab2f0f496c0a1e5c79c",
|
|
1908
|
+
"mode": 493,
|
|
1909
|
+
"origin": "kit"
|
|
1910
|
+
},
|
|
1868
1911
|
{
|
|
1869
1912
|
"path": "scripts/loc_offender_core.py",
|
|
1870
1913
|
"kind": "script",
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikon85/agent-workflow-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"agent-workflow-kit": "src/cli.mjs"
|
|
7
|
+
"agent-workflow-kit": "src/cli.mjs",
|
|
8
|
+
"agent-workflow-kit-update-pr": "scripts/kit-update-pr.mjs"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"test": "npm run test:node && npm run test:python",
|
|
@@ -37,6 +37,9 @@ test('current build contains post-tag public files and repository metadata', asy
|
|
|
37
37
|
const pkg = JSON.parse(await readFile(join(dist, 'package.json'), 'utf8'));
|
|
38
38
|
assert.equal(pkg.name, '@ikon85/agent-workflow-kit');
|
|
39
39
|
assert.equal(pkg.repository.url, 'git+https://github.com/iKon85/agent-workflow-kit.git');
|
|
40
|
+
assert.equal(pkg.bin['agent-workflow-kit'], 'src/cli.mjs');
|
|
41
|
+
assert.equal(pkg.bin['agent-workflow-kit-update-pr'], 'scripts/kit-update-pr.mjs');
|
|
42
|
+
await readFile(join(dist, 'scripts/kit-update-pr.mjs'));
|
|
40
43
|
});
|
|
41
44
|
});
|
|
42
45
|
|
|
@@ -52,9 +55,31 @@ test('npm pack keeps the complete scripts tree but excludes Python caches', () =
|
|
|
52
55
|
const files = JSON.parse(output)[0].files.map((file) => file.path);
|
|
53
56
|
assert.ok(files.includes('scripts/build-kit.mjs'));
|
|
54
57
|
assert.ok(files.includes('scripts/board-sync.py'));
|
|
58
|
+
assert.ok(files.includes('scripts/kit-update-pr.mjs'));
|
|
55
59
|
assert.ok(files.every((path) => !path.includes('__pycache__') && !path.endsWith('.pyc')));
|
|
56
60
|
const pkg = JSON.parse(execFileSync('node', ['-p', 'JSON.stringify(require("./package.json"))'], {
|
|
57
61
|
cwd: REPO, encoding: 'utf8',
|
|
58
62
|
}));
|
|
59
63
|
assert.ok(pkg.files.includes('scripts/'), 'package must ship future script file types');
|
|
60
64
|
});
|
|
65
|
+
|
|
66
|
+
test('packed scoped artifact keeps the existing npx default-bin inference', async () => {
|
|
67
|
+
const destination = await mkdtemp(join(tmpdir(), 'awkit-pack-'));
|
|
68
|
+
try {
|
|
69
|
+
const packed = JSON.parse(execFileSync('npm', [
|
|
70
|
+
'pack', '--pack-destination', destination, '--json',
|
|
71
|
+
], { cwd: REPO, encoding: 'utf8' }));
|
|
72
|
+
const output = execFileSync('npx', [
|
|
73
|
+
'--yes', '--offline', `./${packed[0].filename}`,
|
|
74
|
+
], { cwd: destination, encoding: 'utf8' });
|
|
75
|
+
assert.match(output, /Usage: agent-workflow-kit/);
|
|
76
|
+
|
|
77
|
+
const updater = execFileSync('npm', [
|
|
78
|
+
'exec', '--yes', '--offline', `--package=./${packed[0].filename}`,
|
|
79
|
+
'--', 'agent-workflow-kit-update-pr', 'help',
|
|
80
|
+
], { cwd: destination, encoding: 'utf8' });
|
|
81
|
+
assert.match(updater, /Usage: agent-workflow-kit-update-pr/);
|
|
82
|
+
} finally {
|
|
83
|
+
await rm(destination, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { appendFile } from 'node:fs/promises';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
export const UPDATE_BRANCH = 'agent-workflow-kit/update';
|
|
10
|
+
export const UPDATE_TITLE = 'chore: update agent workflow kit';
|
|
11
|
+
const UPDATE_BODY = [
|
|
12
|
+
'Automated, parity-verified update of `@ikon85/agent-workflow-kit`.',
|
|
13
|
+
'',
|
|
14
|
+
'The transactional update candidate passed the consumer test suite before this branch was published.',
|
|
15
|
+
'',
|
|
16
|
+
'This pull request is never merged automatically.',
|
|
17
|
+
].join('\n');
|
|
18
|
+
const exec = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
export async function orchestrateUpdatePullRequest(options) {
|
|
21
|
+
const {
|
|
22
|
+
runUpdate, hasChanges, listPullRequests, publishBranch,
|
|
23
|
+
createPullRequest, updatePullRequest, branch = UPDATE_BRANCH,
|
|
24
|
+
} = options;
|
|
25
|
+
const update = await runUpdate();
|
|
26
|
+
if (update.exitCode !== 0) {
|
|
27
|
+
return { status: update.exitCode === 2 ? 'conflicted' : 'failed', branch, update };
|
|
28
|
+
}
|
|
29
|
+
if (!await hasChanges()) return { status: 'current', branch };
|
|
30
|
+
|
|
31
|
+
const pulls = await listPullRequests(branch);
|
|
32
|
+
if (pulls.length > 1) {
|
|
33
|
+
return { status: 'failed', branch, reason: 'multiple-open-pull-requests' };
|
|
34
|
+
}
|
|
35
|
+
await publishBranch(branch);
|
|
36
|
+
const input = { title: UPDATE_TITLE, body: UPDATE_BODY, branch };
|
|
37
|
+
if (pulls.length === 1) {
|
|
38
|
+
await updatePullRequest(pulls[0].number, input);
|
|
39
|
+
return { status: 'updated', branch, pullRequest: pulls[0].number };
|
|
40
|
+
}
|
|
41
|
+
await createPullRequest(input);
|
|
42
|
+
return { status: 'created', branch };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createSystemAdapters({
|
|
46
|
+
cwd = process.cwd(), env = process.env, execute = exec,
|
|
47
|
+
} = {}) {
|
|
48
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
49
|
+
const command = async (file, args) => {
|
|
50
|
+
try {
|
|
51
|
+
const result = await execute(file, args, { cwd, env, encoding: 'utf8' });
|
|
52
|
+
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (typeof error.code !== 'number') throw error;
|
|
55
|
+
return { exitCode: error.code, stdout: error.stdout || '', stderr: error.stderr || '' };
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const checked = async (file, args) => {
|
|
59
|
+
const result = await command(file, args);
|
|
60
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || `${file} exited ${result.exitCode}`);
|
|
61
|
+
return result.stdout.trim();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
runUpdate: () => command(process.execPath, [join(packageRoot, 'src/cli.mjs'), 'update', '--yes']),
|
|
66
|
+
hasChanges: async () => Boolean(await checked('git', ['status', '--porcelain', '--untracked-files=all'])),
|
|
67
|
+
listPullRequests: async (branch) => JSON.parse(await checked('gh', [
|
|
68
|
+
'pr', 'list', '--state', 'open', '--head', branch, '--json', 'number,url',
|
|
69
|
+
]) || '[]'),
|
|
70
|
+
publishBranch: async (branch) => {
|
|
71
|
+
await checked('git', ['config', 'user.name', 'agent-workflow-kit[bot]']);
|
|
72
|
+
await checked('git', ['config', 'user.email', 'agent-workflow-kit[bot]@users.noreply.github.com']);
|
|
73
|
+
await checked('git', ['add', '--all']);
|
|
74
|
+
await checked('git', ['commit', '-m', UPDATE_TITLE]);
|
|
75
|
+
const remote = await checked('git', ['ls-remote', 'origin', `refs/heads/${branch}`]);
|
|
76
|
+
const expected = remote.split(/\s+/)[0] || '';
|
|
77
|
+
await checked('git', [
|
|
78
|
+
'push', `--force-with-lease=refs/heads/${branch}:${expected}`,
|
|
79
|
+
'origin', `HEAD:refs/heads/${branch}`,
|
|
80
|
+
]);
|
|
81
|
+
},
|
|
82
|
+
createPullRequest: ({ title, body, branch }) => checked('gh', [
|
|
83
|
+
'pr', 'create', '--head', branch, '--title', title, '--body', body,
|
|
84
|
+
]),
|
|
85
|
+
updatePullRequest: (number, { title, body }) => checked('gh', [
|
|
86
|
+
'pr', 'edit', String(number), '--title', title, '--body', body,
|
|
87
|
+
]),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runCli(options = {}) {
|
|
92
|
+
let report;
|
|
93
|
+
try {
|
|
94
|
+
report = await orchestrateUpdatePullRequest(createSystemAdapters(options));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
report = { status: 'failed', branch: UPDATE_BRANCH, reason: error.message };
|
|
97
|
+
}
|
|
98
|
+
const rendered = `${JSON.stringify(report, null, 2)}\n`;
|
|
99
|
+
process.stdout.write(rendered);
|
|
100
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
101
|
+
await appendFile(process.env.GITHUB_STEP_SUMMARY, `## Agent Workflow Kit update\n\n\`\`\`json\n${rendered}\`\`\`\n`);
|
|
102
|
+
}
|
|
103
|
+
if (report.status === 'conflicted') process.exitCode = 2;
|
|
104
|
+
else if (report.status === 'failed') process.exitCode = 1;
|
|
105
|
+
return report;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
109
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
110
|
+
if (process.argv.includes('--help') || process.argv.includes('-h') || process.argv.includes('help')) {
|
|
111
|
+
process.stdout.write('Usage: agent-workflow-kit-update-pr\n');
|
|
112
|
+
} else {
|
|
113
|
+
await runCli();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createSystemAdapters, orchestrateUpdatePullRequest } from './kit-update-pr.mjs';
|
|
4
|
+
|
|
5
|
+
function harness({ update = { exitCode: 0 }, changed = true, pulls = [] } = {}) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
return {
|
|
8
|
+
calls,
|
|
9
|
+
options: {
|
|
10
|
+
runUpdate: async () => (calls.push('update'), update),
|
|
11
|
+
hasChanges: async () => (calls.push('changes'), changed),
|
|
12
|
+
listPullRequests: async (branch) => (calls.push(['list', branch]), pulls),
|
|
13
|
+
publishBranch: async (branch) => calls.push(['publish', branch]),
|
|
14
|
+
createPullRequest: async (input) => calls.push(['create', input]),
|
|
15
|
+
updatePullRequest: async (number, input) => calls.push(['upsert', number, input]),
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('a successful update creates one stable update pull request', async () => {
|
|
21
|
+
const h = harness();
|
|
22
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
23
|
+
|
|
24
|
+
assert.equal(report.status, 'created');
|
|
25
|
+
assert.equal(report.branch, 'agent-workflow-kit/update');
|
|
26
|
+
assert.deepEqual(h.calls.map((call) => Array.isArray(call) ? call[0] : call), [
|
|
27
|
+
'update', 'changes', 'list', 'publish', 'create',
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a conflict produces a structured report without touching the stable branch', async () => {
|
|
32
|
+
const h = harness({ update: { exitCode: 2, stdout: 'conflicts: 1', stderr: '' } });
|
|
33
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(report, {
|
|
36
|
+
status: 'conflicted',
|
|
37
|
+
branch: 'agent-workflow-kit/update',
|
|
38
|
+
update: { exitCode: 2, stdout: 'conflicts: 1', stderr: '' },
|
|
39
|
+
});
|
|
40
|
+
assert.deepEqual(h.calls, ['update']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a release mismatch fails without touching the consumer update branch', async () => {
|
|
44
|
+
const h = harness({ update: { exitCode: 1, stdout: '', stderr: 'release mismatch' } });
|
|
45
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
46
|
+
|
|
47
|
+
assert.equal(report.status, 'failed');
|
|
48
|
+
assert.equal(report.update.stderr, 'release mismatch');
|
|
49
|
+
assert.deepEqual(h.calls, ['update']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('ambiguous existing pull requests fail before the last good branch is changed', async () => {
|
|
53
|
+
const h = harness({ pulls: [{ number: 7 }, { number: 9 }] });
|
|
54
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
55
|
+
|
|
56
|
+
assert.equal(report.status, 'failed');
|
|
57
|
+
assert.equal(report.reason, 'multiple-open-pull-requests');
|
|
58
|
+
assert.deepEqual(h.calls.map((call) => Array.isArray(call) ? call[0] : call), [
|
|
59
|
+
'update', 'changes', 'list',
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('an already current consumer is a no-op', async () => {
|
|
64
|
+
const h = harness({ changed: false });
|
|
65
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
66
|
+
|
|
67
|
+
assert.equal(report.status, 'current');
|
|
68
|
+
assert.deepEqual(h.calls, ['update', 'changes']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a repeated run updates the one existing stable pull request', async () => {
|
|
72
|
+
const h = harness({ pulls: [{ number: 7 }] });
|
|
73
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
74
|
+
|
|
75
|
+
assert.equal(report.status, 'updated');
|
|
76
|
+
assert.equal(report.pullRequest, 7);
|
|
77
|
+
assert.deepEqual(h.calls.map((call) => Array.isArray(call) ? call[0] : call), [
|
|
78
|
+
'update', 'changes', 'list', 'publish', 'upsert',
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('publishing commits in place and lease-protects the stable remote branch', async () => {
|
|
83
|
+
const commands = [];
|
|
84
|
+
const execute = async (file, args) => {
|
|
85
|
+
commands.push([file, ...args]);
|
|
86
|
+
if (args[0] === 'ls-remote') return { stdout: 'abc123\trefs/heads/agent-workflow-kit/update\n', stderr: '' };
|
|
87
|
+
return { stdout: '', stderr: '' };
|
|
88
|
+
};
|
|
89
|
+
const adapter = createSystemAdapters({ cwd: '/consumer', env: {}, execute });
|
|
90
|
+
|
|
91
|
+
await adapter.publishBranch('agent-workflow-kit/update');
|
|
92
|
+
|
|
93
|
+
const gitCommands = commands.map((command) => command.slice(1));
|
|
94
|
+
assert.equal(gitCommands.some(([verb]) => ['switch', 'checkout', 'reset'].includes(verb)), false);
|
|
95
|
+
assert.deepEqual(gitCommands.at(-1), [
|
|
96
|
+
'push', '--force-with-lease=refs/heads/agent-workflow-kit/update:abc123',
|
|
97
|
+
'origin', 'HEAD:refs/heads/agent-workflow-kit/update',
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
@@ -45,6 +45,16 @@ def classify(first_line, is_empty):
|
|
|
45
45
|
return "create" if is_empty else "skip"
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def update_workflow_action(
|
|
49
|
+
provider, choice, destination_exists, prerequisites=True,
|
|
50
|
+
pull_requests_allowed=True,
|
|
51
|
+
):
|
|
52
|
+
"""Reference decision table for the prompt-driven setup contract."""
|
|
53
|
+
if provider != "github" or destination_exists or choice != "enable":
|
|
54
|
+
return "skip"
|
|
55
|
+
return "create" if prerequisites and pull_requests_allowed else "skip"
|
|
56
|
+
|
|
57
|
+
|
|
48
58
|
class IdempotencyRule(unittest.TestCase):
|
|
49
59
|
CASES = [
|
|
50
60
|
# (first_line, is_empty, expected)
|
|
@@ -71,6 +81,61 @@ class IdempotencyRule(unittest.TestCase):
|
|
|
71
81
|
|
|
72
82
|
|
|
73
83
|
class SeedTemplatesValid(unittest.TestCase):
|
|
84
|
+
def test_update_workflow_provider_and_choice_fixtures(self):
|
|
85
|
+
fixtures = [
|
|
86
|
+
("github", "enable", False, True, True, "create"),
|
|
87
|
+
("github", "opt-out", False, True, True, "skip"),
|
|
88
|
+
("github", "later", False, True, True, "skip"),
|
|
89
|
+
("github", "enable", True, True, True, "skip"),
|
|
90
|
+
("github", "enable", False, False, True, "skip"),
|
|
91
|
+
("github", "enable", False, True, False, "skip"),
|
|
92
|
+
("gitlab", "enable", False, True, True, "skip"),
|
|
93
|
+
("local", "enable", False, True, True, "skip"),
|
|
94
|
+
]
|
|
95
|
+
for provider, choice, exists, prerequisites, allowed, expected in fixtures:
|
|
96
|
+
self.assertEqual(
|
|
97
|
+
update_workflow_action(
|
|
98
|
+
provider, choice, exists, prerequisites, allowed,
|
|
99
|
+
), expected,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_github_update_opt_in_is_provider_aware_and_idempotent(self):
|
|
103
|
+
skill = (SKILL / "SKILL.md").read_text(encoding="utf-8")
|
|
104
|
+
for token in (
|
|
105
|
+
"Automatic Kit update pull requests",
|
|
106
|
+
".github/workflows/agent-workflow-kit-update.yml",
|
|
107
|
+
"Opt out",
|
|
108
|
+
"Ask later",
|
|
109
|
+
"GitHub tracker",
|
|
110
|
+
"skipped (already present)",
|
|
111
|
+
"package-lock.json",
|
|
112
|
+
"npm test",
|
|
113
|
+
"can_approve_pull_request_reviews",
|
|
114
|
+
"Allow GitHub Actions to create and approve pull requests",
|
|
115
|
+
"explicit confirmation",
|
|
116
|
+
):
|
|
117
|
+
self.assertIn(token, skill)
|
|
118
|
+
self.assertIn("do not create a GitHub workflow", skill)
|
|
119
|
+
|
|
120
|
+
def test_github_update_workflow_has_safe_triggers_and_scoped_runner(self):
|
|
121
|
+
workflow = (SKILL / "assets/agent-workflow-kit-update.yml").read_text(encoding="utf-8")
|
|
122
|
+
for token in (
|
|
123
|
+
"schedule:", "workflow_dispatch:", "contents: write",
|
|
124
|
+
"pull-requests: write", "agent-workflow-kit-update-pr",
|
|
125
|
+
"@ikon85/agent-workflow-kit@latest", "fetch-depth: 0",
|
|
126
|
+
"node-version: 22.14", "npm ci --ignore-scripts",
|
|
127
|
+
):
|
|
128
|
+
self.assertIn(token, workflow)
|
|
129
|
+
for forbidden in ("npm_token", "NPM_TOKEN", "auto-merge", "gh pr merge"):
|
|
130
|
+
self.assertNotIn(forbidden, workflow)
|
|
131
|
+
|
|
132
|
+
mirror = (REPO / ".agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml")
|
|
133
|
+
self.assertEqual(workflow, mirror.read_text(encoding="utf-8"))
|
|
134
|
+
|
|
135
|
+
def test_update_pr_runner_is_an_installed_package_binary(self):
|
|
136
|
+
package = json.loads((REPO / "package.json").read_text(encoding="utf-8"))
|
|
137
|
+
self.assertEqual(package["bin"]["agent-workflow-kit-update-pr"], "scripts/kit-update-pr.mjs")
|
|
138
|
+
|
|
74
139
|
def test_spec_completeness_seed_has_valid_self_critique_block(self):
|
|
75
140
|
"""A convention without a valid Trigger/Check/Korrektur block makes
|
|
76
141
|
spec-self-critique point 8 warn — the seed must carry one (Codex R1 #14)."""
|
package/src/cli.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { init } from './commands/init.mjs';
|
|
|
6
6
|
import { update } from './commands/update.mjs';
|
|
7
7
|
import { diff } from './commands/diff.mjs';
|
|
8
8
|
import { uninstall } from './commands/uninstall.mjs';
|
|
9
|
+
import { createCommandAdapter } from '../scripts/release-state.mjs';
|
|
9
10
|
|
|
10
11
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
12
|
const consumerRoot = process.cwd();
|
|
@@ -42,11 +43,22 @@ try {
|
|
|
42
43
|
const ok = await p.confirm({ message: `Upstream removed ${path} — delete it locally?` });
|
|
43
44
|
return ok === true;
|
|
44
45
|
};
|
|
45
|
-
const
|
|
46
|
+
const releaseIdentities = await readUpdateRelease();
|
|
47
|
+
const r = await update({
|
|
48
|
+
kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide, releaseIdentities,
|
|
49
|
+
});
|
|
46
50
|
printPlan(r);
|
|
47
|
-
for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
|
|
52
|
+
if (r.state === 'failed') throw new Error(`candidate update failed: ${r.error}`);
|
|
53
|
+
if (r.state === 'conflicted') {
|
|
54
|
+
p.note(r.report.recommendation, 'recommendation');
|
|
55
|
+
p.outro(`not applied · conflicts ${r.conflicts.length}`);
|
|
56
|
+
process.exitCode = 2;
|
|
57
|
+
} else if (r.status === 'current') {
|
|
58
|
+
p.outro(`aktuell · unchanged ${r.unchanged.length} · local modifications ${r.userModified.length}`);
|
|
59
|
+
} else {
|
|
60
|
+
p.outro(`updated ${r.updated.length} · added ${r.added.length} · deleted ${r.deleted.length}`);
|
|
61
|
+
}
|
|
50
62
|
} else if (cmd === 'uninstall') {
|
|
51
63
|
const ok = yes || (await p.confirm({ message: 'Remove kit-installed files?' })) === true;
|
|
52
64
|
if (!ok) { p.cancel('Aborted.'); process.exit(0); }
|
|
@@ -68,3 +80,16 @@ function printPlan(r) {
|
|
|
68
80
|
if (r.conflicts?.length) lines.push(`conflicts: ${r.conflicts.length}`);
|
|
69
81
|
p.note(lines.join('\n') || 'no changes', 'plan');
|
|
70
82
|
}
|
|
83
|
+
|
|
84
|
+
async function readUpdateRelease() {
|
|
85
|
+
const adapter = await createCommandAdapter({
|
|
86
|
+
repoRoot: KIT_ROOT,
|
|
87
|
+
env: { ...process.env, GH_REPO: 'iKon85/agent-workflow-kit' },
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
const local = (await adapter.local()).identity;
|
|
91
|
+
return { local, npm: await adapter.npm(local), github: await adapter.github(local) };
|
|
92
|
+
} finally {
|
|
93
|
+
await adapter.dispose();
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/commands/update.mjs
CHANGED
|
@@ -1,95 +1,129 @@
|
|
|
1
|
-
import { readFile,
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { assertReleaseParity } from '../../scripts/release-parity.mjs';
|
|
4
|
+
import { activateCandidate, stageConsumer, verifyCandidate } from '../lib/updateCandidate.mjs';
|
|
5
|
+
import { reconcile } from '../lib/updateReconcile.mjs';
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
PACKAGE_MANIFEST_NAME, CONSUMER_MANIFEST_NAME, indexByPath,
|
|
7
|
+
CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, readManifest,
|
|
9
8
|
} from '../lib/manifest.mjs';
|
|
10
9
|
|
|
11
|
-
const
|
|
10
|
+
const RELEASE_NAME = '@ikon85/agent-workflow-kit';
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - unmodified + upstream changed → atomic overwrite (`updated`)
|
|
17
|
-
* - user-edited + upstream changed → timestamped backup + diff, NOT clobbered (`conflicts`)
|
|
18
|
-
* - user-edited + upstream same → left as-is (`userModified`)
|
|
19
|
-
* - new upstream file → installed (`added`)
|
|
20
|
-
* - upstream-removed + unmodified → offered for deletion via `decide` (`deleted`/`keptDeleted`)
|
|
21
|
-
* - a hook file referenced by the consumer's settings is never auto-deleted (R3#7)
|
|
22
|
-
* `dryRun` classifies without writing (powers `diff`). `now` = backup stamp.
|
|
13
|
+
* Transactionally reconcile a consumer with a parity-proven kit release.
|
|
14
|
+
* checking -> preview/awaiting_decision -> staging -> verifying -> terminal state.
|
|
23
15
|
*/
|
|
24
|
-
export async function update(
|
|
16
|
+
export async function update(options) {
|
|
17
|
+
const {
|
|
18
|
+
kitRoot, consumerRoot, decide = () => false, dryRun = false,
|
|
19
|
+
releaseIdentities, verify = verifyCandidate, signal, onState = () => {}, resumeFrom,
|
|
20
|
+
} = options;
|
|
21
|
+
const history = [];
|
|
22
|
+
const transition = async (state) => { history.push(state); await onState(state); };
|
|
23
|
+
|
|
24
|
+
await transition('checking');
|
|
25
25
|
const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
|
|
26
26
|
if (!pkg) throw new Error('kit package manifest not found');
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const nextInstalled = [];
|
|
34
|
-
|
|
35
|
-
for (const f of pkg.files) {
|
|
36
|
-
const dest = join(consumerRoot, f.path);
|
|
37
|
-
const prior = installedIdx.get(f.path);
|
|
38
|
-
const cur = (await exists(dest)) ? await sha256File(dest) : null;
|
|
39
|
-
|
|
40
|
-
if (!prior || cur === null) {
|
|
41
|
-
if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, f.path)), f.mode);
|
|
42
|
-
nextInstalled.push(entry(f, f.sha256));
|
|
43
|
-
res.added.push(f.path);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
const userEdited = cur !== prior.installedSha256;
|
|
47
|
-
const upstreamChanged = f.sha256 !== prior.installedSha256;
|
|
27
|
+
if (!dryRun) verifyRelease(releaseIdentities, pkg.kitVersion);
|
|
28
|
+
const consumerManifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
|
|
29
|
+
if (!await readManifest(consumerManifestPath)) {
|
|
30
|
+
throw new Error('not initialised — run `init` first');
|
|
31
|
+
}
|
|
32
|
+
const consumerManifestBefore = await readFile(consumerManifestPath);
|
|
48
33
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
res.userModified.push(f.path);
|
|
62
|
-
} else {
|
|
63
|
-
nextInstalled.push(prior);
|
|
64
|
-
res.unchanged.push(f.path);
|
|
65
|
-
}
|
|
34
|
+
const decisions = new Map();
|
|
35
|
+
const choose = async (_action, path) => {
|
|
36
|
+
if (!decisions.has(path)) decisions.set(path, await decide('delete', path));
|
|
37
|
+
return decisions.get(path);
|
|
38
|
+
};
|
|
39
|
+
const preview = await reconcile({ kitRoot, consumerRoot, decide: choose, dryRun: true });
|
|
40
|
+
await transition('preview');
|
|
41
|
+
if (preview.deleted.length || preview.keptDeleted.length) await transition('awaiting_decision');
|
|
42
|
+
if (dryRun) return { ...preview, state: 'preview', history };
|
|
43
|
+
if (preview.conflicts.length) return terminal(preview, 'conflicted', history, transition);
|
|
44
|
+
if (!hasUpstreamDelta(preview)) {
|
|
45
|
+
return { ...await terminal(preview, 'applied', history, transition), status: 'current' };
|
|
66
46
|
}
|
|
47
|
+
return applyTransaction({
|
|
48
|
+
kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
|
|
49
|
+
consumerManifestBefore, history, transition,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
67
52
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
53
|
+
async function applyTransaction(context) {
|
|
54
|
+
const {
|
|
55
|
+
kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
|
|
56
|
+
consumerManifestBefore, history, transition,
|
|
57
|
+
} = context;
|
|
58
|
+
let candidateRoot = resumeFrom;
|
|
59
|
+
let keepCandidate = false;
|
|
60
|
+
try {
|
|
61
|
+
await transition('staging');
|
|
62
|
+
if (!candidateRoot) {
|
|
63
|
+
candidateRoot = await stageConsumer(consumerRoot);
|
|
64
|
+
await reconcile({
|
|
65
|
+
kitRoot, consumerRoot: candidateRoot,
|
|
66
|
+
decide: (_action, path) => decisions.get(path) === true,
|
|
67
|
+
});
|
|
81
68
|
}
|
|
69
|
+
await transition('verifying');
|
|
70
|
+
const abort = async () => {
|
|
71
|
+
keepCandidate = true;
|
|
72
|
+
return { ...await terminal(preview, 'aborted', history, transition), candidateRoot };
|
|
73
|
+
};
|
|
74
|
+
if (signal?.aborted) return abort();
|
|
75
|
+
await verify(candidateRoot);
|
|
76
|
+
if (signal?.aborted) return abort();
|
|
77
|
+
await activateCandidate({
|
|
78
|
+
candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
|
|
79
|
+
});
|
|
80
|
+
return { ...await terminal(preview, 'applied', history, transition), status: 'updated' };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return { ...await terminal(preview, 'failed', history, transition), error: error.message };
|
|
83
|
+
} finally {
|
|
84
|
+
if (candidateRoot && !keepCandidate) await rm(candidateRoot, { recursive: true, force: true });
|
|
82
85
|
}
|
|
86
|
+
}
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
88
|
+
function verifyRelease(identities, kitVersion) {
|
|
89
|
+
const release = assertReleaseParity(identities);
|
|
90
|
+
if (release.name !== RELEASE_NAME) throw new Error(`invalid release origin: ${release.name}`);
|
|
91
|
+
if (release.version !== kitVersion) {
|
|
92
|
+
throw new Error(`release version ${release.version} does not match kit ${kitVersion}`);
|
|
89
93
|
}
|
|
90
|
-
return res;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
function
|
|
94
|
-
return
|
|
96
|
+
function hasUpstreamDelta(result) {
|
|
97
|
+
return result.added.length + result.updated.length + result.deleted.length > 0;
|
|
95
98
|
}
|
|
99
|
+
|
|
100
|
+
async function terminal(result, state, history, transition) {
|
|
101
|
+
await transition(state);
|
|
102
|
+
return {
|
|
103
|
+
...result,
|
|
104
|
+
state,
|
|
105
|
+
history,
|
|
106
|
+
report: {
|
|
107
|
+
unchanged: result.unchanged.length,
|
|
108
|
+
added: result.added.length,
|
|
109
|
+
updated: result.updated.length,
|
|
110
|
+
deleted: result.deleted.length,
|
|
111
|
+
localModified: result.userModified.length,
|
|
112
|
+
conflicts: result.conflicts.length,
|
|
113
|
+
keptDeleted: result.keptDeleted.length,
|
|
114
|
+
paths: {
|
|
115
|
+
added: result.added,
|
|
116
|
+
updated: result.updated,
|
|
117
|
+
deleted: result.deleted,
|
|
118
|
+
localModified: result.userModified,
|
|
119
|
+
conflicts: result.conflicts.map(({ path }) => path),
|
|
120
|
+
keptDeleted: result.keptDeleted,
|
|
121
|
+
},
|
|
122
|
+
recommendation: result.conflicts.length
|
|
123
|
+
? 'Review each named conflict; keep the local file or apply the incoming diff manually.'
|
|
124
|
+
: null,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { verifyCandidate } from '../lib/updateCandidate.mjs';
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -47,6 +47,9 @@ export const HELPER_FILES = [
|
|
|
47
47
|
// the parity primitive rather than growing a second registry/GitHub comparison.
|
|
48
48
|
{ path: 'scripts/release-parity.mjs', kind: 'script', mode: 0o644 },
|
|
49
49
|
{ path: 'scripts/release-state.mjs', kind: 'script', mode: 0o644 },
|
|
50
|
+
// GitHub-consumer automation: invokes the existing update command, then owns
|
|
51
|
+
// only the stable tested branch/pull-request upsert.
|
|
52
|
+
{ path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
|
|
50
53
|
// Shared hook utility imported by the shipped hooks (drift-guard,
|
|
51
54
|
// sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
|
|
52
55
|
// hooks ImportError on arrival.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, cp, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { writeAtomic } from './atomicWrite.mjs';
|
|
7
|
+
import { sha256File } from './hash.mjs';
|
|
8
|
+
import { CONSUMER_MANIFEST_NAME, indexByPath, readManifest } from './manifest.mjs';
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const exists = (path) => access(path).then(() => true, () => false);
|
|
12
|
+
|
|
13
|
+
/** Copy a verification candidate without duplicating git metadata or dependencies. */
|
|
14
|
+
export async function stageConsumer(consumerRoot) {
|
|
15
|
+
const candidateRoot = await mkdtemp(join(tmpdir(), 'agent-workflow-kit-stage-'));
|
|
16
|
+
const nodeModules = join(consumerRoot, 'node_modules');
|
|
17
|
+
await cp(consumerRoot, candidateRoot, {
|
|
18
|
+
recursive: true,
|
|
19
|
+
filter: (source) => {
|
|
20
|
+
const rel = relative(consumerRoot, source);
|
|
21
|
+
return rel !== '.git' && !rel.startsWith('.git/') &&
|
|
22
|
+
rel !== 'node_modules' && !rel.startsWith('node_modules/');
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
if (await exists(nodeModules)) await symlink(nodeModules, join(candidateRoot, 'node_modules'), 'dir');
|
|
26
|
+
return candidateRoot;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Activate only verified kit-owned deltas, rolling every touched path back on failure. */
|
|
30
|
+
export async function activateCandidate({
|
|
31
|
+
candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
|
|
32
|
+
}) {
|
|
33
|
+
const changed = [...preview.added, ...preview.updated];
|
|
34
|
+
const touched = [...changed, ...preview.deleted, CONSUMER_MANIFEST_NAME];
|
|
35
|
+
const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
36
|
+
if (!currentManifest.equals(consumerManifestBefore)) {
|
|
37
|
+
throw new Error('consumer manifest changed during verification');
|
|
38
|
+
}
|
|
39
|
+
const pkgIdx = indexByPath(pkg, 'files');
|
|
40
|
+
for (const path of changed) {
|
|
41
|
+
if (await sha256File(join(candidateRoot, path)) !== pkgIdx.get(path)?.sha256) {
|
|
42
|
+
throw new Error(`candidate hash mismatch: ${path}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
await assertConsumerStillMatchesPreview(consumerRoot, preview);
|
|
46
|
+
const rollback = new Map();
|
|
47
|
+
for (const path of touched) rollback.set(path, await snapshot(join(consumerRoot, path)));
|
|
48
|
+
try {
|
|
49
|
+
for (const path of changed) {
|
|
50
|
+
await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)), pkgIdx.get(path)?.mode);
|
|
51
|
+
}
|
|
52
|
+
for (const path of preview.deleted) await rm(join(consumerRoot, path), { force: true });
|
|
53
|
+
await writeAtomic(
|
|
54
|
+
join(consumerRoot, CONSUMER_MANIFEST_NAME),
|
|
55
|
+
await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME)),
|
|
56
|
+
);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
for (const path of touched.reverse()) await restore(join(consumerRoot, path), rollback.get(path));
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
|
|
64
|
+
const manifest = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
65
|
+
const installed = indexByPath(manifest, 'installed');
|
|
66
|
+
for (const path of preview.added) {
|
|
67
|
+
if (await exists(join(consumerRoot, path))) throw new Error(`consumer changed during verification: ${path}`);
|
|
68
|
+
}
|
|
69
|
+
for (const path of [...preview.updated, ...preview.deleted]) {
|
|
70
|
+
const prior = installed.get(path);
|
|
71
|
+
const current = await exists(join(consumerRoot, path))
|
|
72
|
+
? await sha256File(join(consumerRoot, path)) : null;
|
|
73
|
+
if (!prior || current !== prior.installedSha256) {
|
|
74
|
+
throw new Error(`consumer changed during verification: ${path}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function snapshot(path) {
|
|
80
|
+
if (!await exists(path)) return null;
|
|
81
|
+
const info = await stat(path);
|
|
82
|
+
return { bytes: await readFile(path), mode: info.mode };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function restore(path, saved) {
|
|
86
|
+
if (!saved) return rm(path, { force: true });
|
|
87
|
+
await writeAtomic(path, saved.bytes, saved.mode);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Default candidate gate: run the consumer's existing npm test command. */
|
|
91
|
+
export async function verifyCandidate(candidateRoot) {
|
|
92
|
+
let pkg;
|
|
93
|
+
try {
|
|
94
|
+
pkg = JSON.parse(await readFile(join(candidateRoot, 'package.json'), 'utf8'));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error.code === 'ENOENT') throw new Error('candidate has no package.json test command');
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
if (!pkg.scripts?.test) throw new Error('candidate has no package.json test command');
|
|
100
|
+
await run('npm', ['test'], { cwd: candidateRoot });
|
|
101
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { access, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { sha256File } from './hash.mjs';
|
|
4
|
+
import { lineDiff, writeAtomic } from './atomicWrite.mjs';
|
|
5
|
+
import { hookReferenced } from './settings.mjs';
|
|
6
|
+
import {
|
|
7
|
+
CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, emptyConsumerManifest,
|
|
8
|
+
indexByPath, readManifest, writeManifest,
|
|
9
|
+
} from './manifest.mjs';
|
|
10
|
+
|
|
11
|
+
const exists = (path) => access(path).then(() => true, () => false);
|
|
12
|
+
|
|
13
|
+
/** Classify or apply one three-way reconcile inside a supplied root. */
|
|
14
|
+
export async function reconcile({ kitRoot, consumerRoot, decide = () => false, dryRun = false }) {
|
|
15
|
+
const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
|
|
16
|
+
if (!pkg) throw new Error('kit package manifest not found');
|
|
17
|
+
const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
18
|
+
if (!consumer) throw new Error('not initialised — run `init` first');
|
|
19
|
+
|
|
20
|
+
const installedIdx = indexByPath(consumer, 'installed');
|
|
21
|
+
const pkgIdx = indexByPath(pkg, 'files');
|
|
22
|
+
const result = emptyResult();
|
|
23
|
+
const nextInstalled = [];
|
|
24
|
+
|
|
25
|
+
for (const file of pkg.files) {
|
|
26
|
+
const dest = join(consumerRoot, file.path);
|
|
27
|
+
const prior = installedIdx.get(file.path);
|
|
28
|
+
const current = (await exists(dest)) ? await sha256File(dest) : null;
|
|
29
|
+
if (!prior || current === null) {
|
|
30
|
+
if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
|
|
31
|
+
nextInstalled.push(entry(file, file.sha256));
|
|
32
|
+
result.added.push(file.path);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const userEdited = current !== prior.installedSha256;
|
|
36
|
+
const upstreamChanged = file.sha256 !== prior.installedSha256;
|
|
37
|
+
if (!userEdited && upstreamChanged) {
|
|
38
|
+
if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
|
|
39
|
+
nextInstalled.push(entry(file, file.sha256));
|
|
40
|
+
result.updated.push(file.path);
|
|
41
|
+
} else if (userEdited && upstreamChanged) {
|
|
42
|
+
const incoming = await readFile(join(kitRoot, file.path), 'utf8');
|
|
43
|
+
result.conflicts.push({ path: file.path, diff: lineDiff(await readFile(dest, 'utf8'), incoming) });
|
|
44
|
+
nextInstalled.push(prior);
|
|
45
|
+
} else if (userEdited) {
|
|
46
|
+
nextInstalled.push(prior);
|
|
47
|
+
result.userModified.push(file.path);
|
|
48
|
+
} else {
|
|
49
|
+
nextInstalled.push(prior);
|
|
50
|
+
result.unchanged.push(file.path);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const prior of consumer.installed) {
|
|
55
|
+
if (pkgIdx.has(prior.path)) continue;
|
|
56
|
+
const dest = join(consumerRoot, prior.path);
|
|
57
|
+
const current = (await exists(dest)) ? await sha256File(dest) : null;
|
|
58
|
+
const userEdited = current !== null && current !== prior.installedSha256;
|
|
59
|
+
const referenced = prior.kind === 'hook' && (await hookReferenced(consumerRoot, prior.path));
|
|
60
|
+
if (userEdited || referenced || !(await decide('delete', prior.path))) {
|
|
61
|
+
nextInstalled.push(prior);
|
|
62
|
+
result.keptDeleted.push(prior.path);
|
|
63
|
+
} else {
|
|
64
|
+
if (!dryRun && current !== null) await rm(dest);
|
|
65
|
+
result.deleted.push(prior.path);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!dryRun) {
|
|
70
|
+
await writeManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME), {
|
|
71
|
+
...emptyConsumerManifest(pkg.kitVersion), installed: nextInstalled,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function emptyResult() {
|
|
78
|
+
return {
|
|
79
|
+
unchanged: [], updated: [], conflicts: [], userModified: [],
|
|
80
|
+
added: [], deleted: [], keptDeleted: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function entry(file, installedSha256) {
|
|
85
|
+
return {
|
|
86
|
+
path: file.path, kind: file.kind, ownerSkill: file.ownerSkill, surface: file.surface,
|
|
87
|
+
installedSha256, origin: 'kit',
|
|
88
|
+
};
|
|
89
|
+
}
|