@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.
@@ -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
@@ -384,6 +384,16 @@
384
384
  "codex"
385
385
  ],
386
386
  "provenance": "own"
387
+ },
388
+ "kit-update": {
389
+ "class": "generic",
390
+ "publish": true,
391
+ "entryPoint": true,
392
+ "surfaces": [
393
+ "claude",
394
+ "codex"
395
+ ],
396
+ "provenance": "own"
387
397
  }
388
398
  }
389
399
  }
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.11.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": "ea0b75ccd1579820a09e7c8d4c151126aa0be7a0be6b76b23599fadebb44b554",
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": "87e5ab578f1c17905b39b9cb39b515e4661f3437d96de919f1b23a5775760c4f",
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": "ea0b75ccd1579820a09e7c8d4c151126aa0be7a0be6b76b23599fadebb44b554",
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": "87e5ab578f1c17905b39b9cb39b515e4661f3437d96de919f1b23a5775760c4f",
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.11.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 r = await update({ kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide });
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 (backed up): ${c.path}`);
48
- p.outro(`updated ${r.updated.length} · added ${r.added.length} · ` +
49
- `conflicts ${r.conflicts.length} · deleted ${r.deleted.length}`);
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
+ }
@@ -1,95 +1,129 @@
1
- import { readFile, access, rm } from 'node:fs/promises';
1
+ import { readFile, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { sha256File } from '../lib/hash.mjs';
4
- import { writeAtomic, backupFile, lineDiff } from '../lib/atomicWrite.mjs';
5
- import { hookReferenced } from '../lib/settings.mjs';
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
- readManifest, writeManifest, emptyConsumerManifest,
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 exists = (p) => access(p).then(() => true, () => false);
10
+ const RELEASE_NAME = '@ikon85/agent-workflow-kit';
12
11
 
13
12
  /**
14
- * Reconcile an installed consumer against the current kit (three-way: installed
15
- * hash vs current file = user edit?, vs package desired = upstream change?).
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({ kitRoot, consumerRoot, now, decide = () => false, dryRun = false }) {
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
- const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
28
- if (!consumer) throw new Error('not initialised — run `init` first');
29
-
30
- const installedIdx = indexByPath(consumer, 'installed');
31
- const pkgIdx = indexByPath(pkg, 'files');
32
- const res = { unchanged: [], updated: [], conflicts: [], userModified: [], added: [], deleted: [], keptDeleted: [] };
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
- if (!userEdited && upstreamChanged) {
50
- if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, f.path)), f.mode);
51
- nextInstalled.push(entry(f, f.sha256));
52
- res.updated.push(f.path);
53
- } else if (userEdited && upstreamChanged) {
54
- const incoming = await readFile(join(kitRoot, f.path), 'utf8');
55
- const diff = lineDiff(await readFile(dest, 'utf8'), incoming);
56
- if (!dryRun) await backupFile(dest, now);
57
- nextInstalled.push(prior); // keep installed-hash baseline; file stays user's
58
- res.conflicts.push({ path: f.path, diff });
59
- } else if (userEdited) {
60
- nextInstalled.push(prior);
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
- // upstream-removed files (in consumer manifest, gone from package)
69
- for (const prior of consumer.installed) {
70
- if (pkgIdx.has(prior.path)) continue;
71
- const dest = join(consumerRoot, prior.path);
72
- const cur = (await exists(dest)) ? await sha256File(dest) : null;
73
- const userEdited = cur !== null && cur !== prior.installedSha256;
74
- const referenced = prior.kind === 'hook' && (await hookReferenced(consumerRoot, prior.path));
75
- if (userEdited || referenced || !(await decide('delete', prior.path))) {
76
- nextInstalled.push(prior);
77
- res.keptDeleted.push(prior.path);
78
- } else {
79
- if (!dryRun && cur !== null) await rm(dest);
80
- res.deleted.push(prior.path);
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
- if (!dryRun) {
85
- await writeManifest(
86
- join(consumerRoot, CONSUMER_MANIFEST_NAME),
87
- { ...emptyConsumerManifest(pkg.kitVersion), installed: nextInstalled }
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 entry(f, sha) {
94
- return { path: f.path, kind: f.kind, ownerSkill: f.ownerSkill, surface: f.surface, installedSha256: sha, origin: 'kit' };
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';
@@ -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
+ }