@ikon85/agent-workflow-kit 0.11.0 → 0.12.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/workflow-overview.md +1 -0
- package/.claude/skills/kit-update/SKILL.md +52 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/skills/skill-manifest.json +10 -0
- package/README.md +7 -0
- package/agent-workflow-kit.package.json +21 -3
- package/package.json +1 -1
- package/src/cli.mjs +29 -4
- package/src/commands/update.mjs +110 -76
- 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.
|
|
@@ -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.
|
|
@@ -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,13 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
|
|
|
332
332
|
|
|
333
333
|
## Release notes
|
|
334
334
|
|
|
335
|
+
### 0.12.0
|
|
336
|
+
|
|
337
|
+
- added: `.agents/skills/kit-update/SKILL.md`
|
|
338
|
+
- added: `.claude/skills/kit-update/SKILL.md`
|
|
339
|
+
- changed: `.agents/skills/setup-workflow/workflow-overview.md`
|
|
340
|
+
- changed: `.claude/skills/setup-workflow/workflow-overview.md`
|
|
341
|
+
|
|
335
342
|
### 0.11.0
|
|
336
343
|
|
|
337
344
|
- added: `scripts/release-parity.mjs`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kitVersion": "0.
|
|
2
|
+
"kitVersion": "0.12.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",
|
|
@@ -564,7 +573,7 @@
|
|
|
564
573
|
"kind": "skill",
|
|
565
574
|
"ownerSkill": "setup-workflow",
|
|
566
575
|
"surface": "codex",
|
|
567
|
-
"sha256": "
|
|
576
|
+
"sha256": "6ce3954e4f94d112801cba0d145e4d3b6f101aca5e50e6b1f9794aa0288881a9",
|
|
568
577
|
"mode": 420,
|
|
569
578
|
"origin": "kit"
|
|
570
579
|
},
|
|
@@ -1271,6 +1280,15 @@
|
|
|
1271
1280
|
"mode": 420,
|
|
1272
1281
|
"origin": "kit"
|
|
1273
1282
|
},
|
|
1283
|
+
{
|
|
1284
|
+
"path": ".claude/skills/kit-update/SKILL.md",
|
|
1285
|
+
"kind": "skill",
|
|
1286
|
+
"ownerSkill": "kit-update",
|
|
1287
|
+
"surface": "claude",
|
|
1288
|
+
"sha256": "26737ea49ac104260e56fd1604f60249ca1d59f1a2c1f22db55649833d01ebc3",
|
|
1289
|
+
"mode": 420,
|
|
1290
|
+
"origin": "kit"
|
|
1291
|
+
},
|
|
1274
1292
|
{
|
|
1275
1293
|
"path": ".claude/skills/local-ci/SKILL.md",
|
|
1276
1294
|
"kind": "skill",
|
|
@@ -1528,7 +1546,7 @@
|
|
|
1528
1546
|
"kind": "skill",
|
|
1529
1547
|
"ownerSkill": "setup-workflow",
|
|
1530
1548
|
"surface": "claude",
|
|
1531
|
-
"sha256": "
|
|
1549
|
+
"sha256": "6ce3954e4f94d112801cba0d145e4d3b6f101aca5e50e6b1f9794aa0288881a9",
|
|
1532
1550
|
"mode": 420,
|
|
1533
1551
|
"origin": "kit"
|
|
1534
1552
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikon85/agent-workflow-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": {
|
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';
|
|
@@ -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
|
+
}
|