@ikon85/agent-workflow-kit 0.42.1 → 0.43.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/README.md +19 -0
- package/agent-workflow-kit.package.json +4 -4
- package/package.json +11 -1
- package/scripts/build-kit.test.mjs +4 -1
- package/scripts/codex-exec-scenarios/fake-codex.mjs +14 -1
- package/scripts/codex-exec.test.mjs +7 -7
- package/scripts/release-delta-guard.mjs +12 -4
- package/scripts/release-delta-guard.test.mjs +23 -0
- package/scripts/test_worktree_wrapup_contract.py +90 -1
- package/scripts/worktree-lifecycle/README.md +12 -6
- package/scripts/worktree-lifecycle/core.py +149 -9
- package/docs/agents/domain.md +0 -7
- package/docs/agents/issue-tracker.md +0 -23
- package/docs/agents/skills/local-ci.md +0 -90
- package/docs/agents/skills/orchestrate-wave.md +0 -166
- package/docs/agents/skills/spec-self-critique.md +0 -18
- package/docs/agents/triage-labels.md +0 -10
- package/docs/conventions/spec-completeness.md +0 -23
package/README.md
CHANGED
|
@@ -469,6 +469,25 @@ the old way. Decision record:
|
|
|
469
469
|
|
|
470
470
|
## Release notes
|
|
471
471
|
|
|
472
|
+
### 0.43.0
|
|
473
|
+
|
|
474
|
+
- removed: `docs/agents/domain.md`
|
|
475
|
+
- removed: `docs/agents/issue-tracker.md`
|
|
476
|
+
- removed: `docs/agents/skills/local-ci.md`
|
|
477
|
+
- removed: `docs/agents/skills/orchestrate-wave.md`
|
|
478
|
+
- removed: `docs/agents/skills/spec-self-critique.md`
|
|
479
|
+
- removed: `docs/agents/triage-labels.md`
|
|
480
|
+
- removed: `docs/conventions/spec-completeness.md`
|
|
481
|
+
- changed: `package.json`
|
|
482
|
+
- changed: `scripts/build-kit.test.mjs`
|
|
483
|
+
- changed: `scripts/codex-exec-scenarios/fake-codex.mjs`
|
|
484
|
+
- changed: `scripts/codex-exec.test.mjs`
|
|
485
|
+
- changed: `scripts/release-delta-guard.mjs`
|
|
486
|
+
- changed: `scripts/release-delta-guard.test.mjs`
|
|
487
|
+
- changed: `scripts/test_worktree_wrapup_contract.py`
|
|
488
|
+
- changed: `scripts/worktree-lifecycle/README.md`
|
|
489
|
+
- changed: `scripts/worktree-lifecycle/core.py`
|
|
490
|
+
|
|
472
491
|
### 0.42.1
|
|
473
492
|
|
|
474
493
|
- changed: `docs/agents/skills/orchestrate-wave.md`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kitVersion": "0.
|
|
2
|
+
"kitVersion": "0.43.0",
|
|
3
3
|
"files": [
|
|
4
4
|
{
|
|
5
5
|
"path": ".agents/skills/ask-matt/SKILL.md",
|
|
@@ -2699,7 +2699,7 @@
|
|
|
2699
2699
|
"path": "scripts/release-delta-guard.mjs",
|
|
2700
2700
|
"kind": "script",
|
|
2701
2701
|
"installRole": "maintainer",
|
|
2702
|
-
"sha256": "
|
|
2702
|
+
"sha256": "41d9fd9c86324f1428a3de04f2bfcb630e868525d88a8864d7bdfc2954e00414",
|
|
2703
2703
|
"mode": 420,
|
|
2704
2704
|
"origin": "kit"
|
|
2705
2705
|
},
|
|
@@ -2811,7 +2811,7 @@
|
|
|
2811
2811
|
"path": "scripts/worktree-lifecycle/core.py",
|
|
2812
2812
|
"kind": "script",
|
|
2813
2813
|
"installRole": "consumer",
|
|
2814
|
-
"sha256": "
|
|
2814
|
+
"sha256": "2c89528a7beb629eb8b61ced26dd68cd8cd19fe2d37a4934badf74cede51e832",
|
|
2815
2815
|
"mode": 420,
|
|
2816
2816
|
"origin": "kit"
|
|
2817
2817
|
},
|
|
@@ -2843,7 +2843,7 @@
|
|
|
2843
2843
|
"path": "scripts/worktree-lifecycle/README.md",
|
|
2844
2844
|
"kind": "doc",
|
|
2845
2845
|
"installRole": "consumer",
|
|
2846
|
-
"sha256": "
|
|
2846
|
+
"sha256": "a5e89b57eeb817075214af30e6504f4077434b9f90fb08a19aea028144073448",
|
|
2847
2847
|
"mode": 420,
|
|
2848
2848
|
"origin": "kit"
|
|
2849
2849
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikon85/agent-workflow-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.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": {
|
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
"assets/",
|
|
25
25
|
"scripts/",
|
|
26
26
|
"docs/",
|
|
27
|
+
"!docs/agents/issue-tracker.md",
|
|
28
|
+
"!docs/agents/triage-labels.md",
|
|
29
|
+
"!docs/agents/domain.md",
|
|
30
|
+
"!docs/agents/skills/spec-self-critique.md",
|
|
31
|
+
"!docs/agents/skills/orchestrate-wave.md",
|
|
32
|
+
"!docs/agents/skills/local-ci.md",
|
|
33
|
+
"!docs/agents/skills/git-worktree-recover.md",
|
|
34
|
+
"!docs/agents/skills/audit-skills.md",
|
|
35
|
+
"!docs/agents/skills/security-audit.md",
|
|
36
|
+
"!docs/conventions/spec-completeness.md",
|
|
27
37
|
"src/",
|
|
28
38
|
"agent-workflow-kit.package.json",
|
|
29
39
|
"LICENSE",
|
|
@@ -8,7 +8,7 @@ import { createHash } from 'node:crypto';
|
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { buildKit } from './build-kit.mjs';
|
|
10
10
|
import { init } from '../src/commands/init.mjs';
|
|
11
|
-
import { HELPER_FILES } from '../src/lib/bundle.mjs';
|
|
11
|
+
import { HELPER_FILES, STUB_TARGETS } from '../src/lib/bundle.mjs';
|
|
12
12
|
import { CONSUMER_MANIFEST_NAME, readManifest } from '../src/lib/manifest.mjs';
|
|
13
13
|
|
|
14
14
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -155,6 +155,9 @@ test('npm pack keeps product files but excludes runtime residue', async () => {
|
|
|
155
155
|
for (const path of PUBLIC_CENSUS_UNIT) {
|
|
156
156
|
assert.ok(files.includes(path), `pack missing ${path}`);
|
|
157
157
|
}
|
|
158
|
+
for (const path of STUB_TARGETS) {
|
|
159
|
+
assert.ok(!files.includes(path), `pack must exclude init stub target ${path}`);
|
|
160
|
+
}
|
|
158
161
|
const packedByPath = new Map(JSON.parse(output)[0].files.map((file) => [file.path, file]));
|
|
159
162
|
assert.deepEqual(
|
|
160
163
|
WAVE_152_HELPERS.map(({ path, mode }) => ({ path, mode: packedByPath.get(path)?.mode })),
|
|
@@ -52,6 +52,16 @@ const verdict = () => emit({
|
|
|
52
52
|
item: { id: 'fake-item', type: 'agent_message', text: 'fake verdict' },
|
|
53
53
|
});
|
|
54
54
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
|
+
const waitForSignal = () => new Promise((resolve) => {
|
|
56
|
+
const keepAlive = setInterval(() => {}, 2_147_483_647);
|
|
57
|
+
const done = () => {
|
|
58
|
+
clearInterval(keepAlive);
|
|
59
|
+
resolve();
|
|
60
|
+
};
|
|
61
|
+
process.once('SIGTERM', done);
|
|
62
|
+
process.once('SIGINT', done);
|
|
63
|
+
process.once('SIGHUP', done);
|
|
64
|
+
});
|
|
55
65
|
|
|
56
66
|
switch (scenario) {
|
|
57
67
|
case 'silent':
|
|
@@ -129,7 +139,10 @@ switch (scenario) {
|
|
|
129
139
|
if (process.env.FAKE_CODEX_CHILD_PID) {
|
|
130
140
|
writeFileSync(process.env.FAKE_CODEX_CHILD_PID, String(child.pid));
|
|
131
141
|
}
|
|
132
|
-
|
|
142
|
+
if (process.env.FAKE_CODEX_GROUP_HANG_READY) {
|
|
143
|
+
writeFileSync(process.env.FAKE_CODEX_GROUP_HANG_READY, 'ready\n');
|
|
144
|
+
}
|
|
145
|
+
await waitForSignal();
|
|
133
146
|
break;
|
|
134
147
|
}
|
|
135
148
|
case 'orphan-group': {
|
|
@@ -369,15 +369,17 @@ test('automatic stale cleanup is bounded and never removes retained or active st
|
|
|
369
369
|
|
|
370
370
|
test('invalid stale cleanup limits fail before launch and preserve an active run', async () => {
|
|
371
371
|
const fx = fixture();
|
|
372
|
+
const ready = join(fx.dir, 'group-hang.ready');
|
|
372
373
|
const running = spawn(helper, launchArgs('build'), {
|
|
373
374
|
cwd: root, encoding: 'utf8', env: {
|
|
374
375
|
...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot,
|
|
375
|
-
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
376
|
+
FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_GROUP_HANG_READY: ready,
|
|
376
377
|
FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
|
|
377
378
|
},
|
|
378
379
|
});
|
|
379
380
|
running.stdout.on('data', () => {});
|
|
380
381
|
try {
|
|
382
|
+
assert.ok(await waitFor(() => exists(ready)));
|
|
381
383
|
const stateName = await waitFor(() => exists(fx.stateRoot) && readdirSync(fx.stateRoot)
|
|
382
384
|
.find((name) => readJson(join(fx.stateRoot, name, 'runtime.json'))?.phase === 'running'));
|
|
383
385
|
assert.ok(stateName);
|
|
@@ -452,7 +454,6 @@ test('stale cleanup cannot delete a run whose resume acquired the shared lease',
|
|
|
452
454
|
...process.env,
|
|
453
455
|
CODEX_EXEC_STATE_ROOT: fx.stateRoot,
|
|
454
456
|
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
455
|
-
FAKE_CODEX_PAUSE_MS: '5000',
|
|
456
457
|
FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
|
|
457
458
|
},
|
|
458
459
|
});
|
|
@@ -577,7 +578,7 @@ test('timeout kills only the owned group and leaves a decoy sibling alive', () =
|
|
|
577
578
|
try {
|
|
578
579
|
const args = launchArgs().map((value, index, all) => all[index - 1] === '--timeout' ? '0.35' : value);
|
|
579
580
|
const result = invoke(fx, args, {
|
|
580
|
-
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
581
|
+
FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_CHILD_PID: childPid,
|
|
581
582
|
});
|
|
582
583
|
assert.equal(result.output.status, 'TIMEOUT');
|
|
583
584
|
assert.equal(exists(result.output.stateDir), true);
|
|
@@ -598,7 +599,7 @@ test('a concurrent resume and finalize cannot steal an owned round lease', async
|
|
|
598
599
|
], {
|
|
599
600
|
cwd: root, encoding: 'utf8', env: {
|
|
600
601
|
...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot,
|
|
601
|
-
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
602
|
+
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
602
603
|
FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
|
|
603
604
|
},
|
|
604
605
|
});
|
|
@@ -655,7 +656,6 @@ test('a runtime publication failure reaps the owned child before releasing its l
|
|
|
655
656
|
CODEX_EXEC_TEST_CHILD_PID_FILE: childPid,
|
|
656
657
|
CODEX_EXEC_TEST_RUNTIME_WRITE_FAIL: '1',
|
|
657
658
|
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
658
|
-
FAKE_CODEX_PAUSE_MS: '5000',
|
|
659
659
|
});
|
|
660
660
|
assert.notEqual(result.status, 0);
|
|
661
661
|
assert.equal(result.output.error, 'ROUND_SUPERVISOR_FAILED');
|
|
@@ -795,7 +795,7 @@ test('abort cancels an active run, deletes its state, and spares a decoy', async
|
|
|
795
795
|
const running = spawn(helper, launchArgs('build'), {
|
|
796
796
|
cwd: root, encoding: 'utf8', env: {
|
|
797
797
|
...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot, FAKE_CODEX_SCENARIO: 'group-hang',
|
|
798
|
-
|
|
798
|
+
FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
|
|
799
799
|
},
|
|
800
800
|
});
|
|
801
801
|
let stdout = '';
|
|
@@ -823,7 +823,7 @@ test('handle-failure selects one of two simultaneous live runs and leaves the ot
|
|
|
823
823
|
const start = () => spawn(helper, launchArgs('build'), {
|
|
824
824
|
cwd: root, encoding: 'utf8', env: {
|
|
825
825
|
...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot,
|
|
826
|
-
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
826
|
+
FAKE_CODEX_SCENARIO: 'group-hang',
|
|
827
827
|
FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
|
|
828
828
|
},
|
|
829
829
|
});
|
|
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { STUB_TARGETS } from '../src/lib/bundle.mjs';
|
|
7
8
|
import { sha256 } from '../src/lib/hash.mjs';
|
|
8
9
|
import { buildKit } from './build-kit.mjs';
|
|
9
10
|
|
|
@@ -30,9 +31,10 @@ function mergeDeltas(...deltas) {
|
|
|
30
31
|
]));
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function recommendBump(delta) {
|
|
34
|
-
|
|
35
|
-
if (delta.
|
|
34
|
+
export function recommendBump(delta, { minorRemovals = [] } = {}) {
|
|
35
|
+
const minorRemovalSet = new Set(minorRemovals);
|
|
36
|
+
if (delta.removed.some((path) => !minorRemovalSet.has(path))) return 'major';
|
|
37
|
+
if (delta.added.length || delta.removed.length) return 'minor';
|
|
36
38
|
return delta.changed.length ? 'patch' : null;
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -79,7 +81,13 @@ export function assessRelease(input) {
|
|
|
79
81
|
+ `tag and publish it before preparing ${input.currentVersion}`,
|
|
80
82
|
);
|
|
81
83
|
}
|
|
82
|
-
|
|
84
|
+
// Project-layer stubs are seed inputs, never installed Consumer files.
|
|
85
|
+
// Removing an accidentally packed copy is additive hygiene, unless the same
|
|
86
|
+
// path also disappeared from the actual Consumer bundle.
|
|
87
|
+
const seedOnlyPackageRemovals = packageDelta.removed.filter(
|
|
88
|
+
(path) => STUB_TARGETS.includes(path) && !bundleDelta.removed.includes(path),
|
|
89
|
+
);
|
|
90
|
+
const recommendedBump = recommendBump(delta, { minorRemovals: seedOnlyPackageRemovals });
|
|
83
91
|
const actual = bumpKind(input.baseVersion, input.currentVersion);
|
|
84
92
|
const rank = { patch: 1, minor: 2, major: 3 };
|
|
85
93
|
if (hasDelta(delta) && input.currentVersion !== input.baseVersion && !actual) {
|
|
@@ -58,6 +58,29 @@ test('an npm runtime change outside the Consumer manifest still requires a relea
|
|
|
58
58
|
assert.match(result.errors.join('\n'), /version remains 1\.2\.3/);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
test('removing accidentally packed seed-only project stubs is a minor change', () => {
|
|
62
|
+
const consumer = { kitVersion: '1.3.0', files: [file('skill.md', 'same')] };
|
|
63
|
+
const result = assessRelease({
|
|
64
|
+
baseVersion: '1.2.3',
|
|
65
|
+
currentVersion: '1.3.0',
|
|
66
|
+
baseManifest: { ...consumer, kitVersion: '1.2.3' },
|
|
67
|
+
builtManifest: consumer,
|
|
68
|
+
checkedManifest: consumer,
|
|
69
|
+
payloadManifest: consumer,
|
|
70
|
+
basePackagePayload: {
|
|
71
|
+
files: [
|
|
72
|
+
file('src/cli.mjs', 'same'),
|
|
73
|
+
file('docs/agents/issue-tracker.md', 'filled-project-layer'),
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
currentPackagePayload: { files: [file('src/cli.mjs', 'same')] },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.equal(result.ok, true);
|
|
80
|
+
assert.deepEqual(result.delta.removed, ['docs/agents/issue-tracker.md']);
|
|
81
|
+
assert.equal(result.recommendedBump, 'minor');
|
|
82
|
+
});
|
|
83
|
+
|
|
61
84
|
test('dead checked-manifest entries are rejected', () => {
|
|
62
85
|
const result = assessRelease({
|
|
63
86
|
baseVersion: '1.2.3', currentVersion: '1.3.0',
|
|
@@ -51,7 +51,7 @@ def create_merged_worktree(
|
|
|
51
51
|
command(["git", "config", "user.name", "Test"], main)
|
|
52
52
|
command(["git", "config", "user.email", "test@example.invalid"], main)
|
|
53
53
|
(main / ".gitignore").write_text(
|
|
54
|
-
".worktrees/\ndist-kit/\n__pycache__/\n.claude/logs/\nconsumer/\n",
|
|
54
|
+
".worktrees/\ndist-kit/\n__pycache__/\n.claude/logs/\nconsumer/\nnode_modules/\n",
|
|
55
55
|
encoding="utf-8",
|
|
56
56
|
)
|
|
57
57
|
profile = main / "docs/agents/workflow-capabilities.json"
|
|
@@ -773,6 +773,35 @@ class WorktreeCleanupContract(unittest.TestCase):
|
|
|
773
773
|
self.assertFalse(worktree.exists())
|
|
774
774
|
self.assertTrue(second["merged"])
|
|
775
775
|
|
|
776
|
+
def test_pnpm_symlink_farm_reaches_guarded_land_idempotently(self):
|
|
777
|
+
wrapup = load_wrapup()
|
|
778
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
779
|
+
main, worktree = create_merged_worktree(
|
|
780
|
+
Path(tmp), scratch_patterns=["node_modules/**"]
|
|
781
|
+
)
|
|
782
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
783
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
784
|
+
str(worktree), str(main), push_succeeded=True
|
|
785
|
+
)
|
|
786
|
+
package = (
|
|
787
|
+
worktree
|
|
788
|
+
/ "node_modules/.pnpm/example@1.0.0/node_modules/example"
|
|
789
|
+
)
|
|
790
|
+
package.mkdir(parents=True)
|
|
791
|
+
(package / "index.js").write_text(
|
|
792
|
+
"export default 1;\n", encoding="utf-8"
|
|
793
|
+
)
|
|
794
|
+
(worktree / "node_modules/example").symlink_to(
|
|
795
|
+
".pnpm/example@1.0.0/node_modules/example"
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
first = run_land(wrapup, main, land_args())
|
|
799
|
+
second = run_land(wrapup, main, land_args())
|
|
800
|
+
|
|
801
|
+
self.assertEqual(first["worktree_removed"], str(worktree))
|
|
802
|
+
self.assertFalse(worktree.exists())
|
|
803
|
+
self.assertTrue(second["merged"])
|
|
804
|
+
|
|
776
805
|
def test_append_after_profile_log_assessment_is_preserved_and_stops(self):
|
|
777
806
|
wrapup = load_wrapup()
|
|
778
807
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -934,6 +963,66 @@ class WorktreeCleanupContract(unittest.TestCase):
|
|
|
934
963
|
self.assertTrue(generated.exists())
|
|
935
964
|
self.assertTrue(late.exists())
|
|
936
965
|
|
|
966
|
+
def test_late_profile_scratch_symlink_stops_before_assessed_links_are_removed(self):
|
|
967
|
+
wrapup = load_wrapup()
|
|
968
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
969
|
+
main, worktree = create_merged_worktree(
|
|
970
|
+
Path(tmp), scratch_patterns=["node_modules/**"]
|
|
971
|
+
)
|
|
972
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
973
|
+
packages = worktree / "node_modules/.pnpm"
|
|
974
|
+
first_target = packages / "first@1.0.0/node_modules/first"
|
|
975
|
+
first_target.mkdir(parents=True)
|
|
976
|
+
first = worktree / "node_modules/first"
|
|
977
|
+
first.symlink_to(".pnpm/first@1.0.0/node_modules/first")
|
|
978
|
+
assessment = wrapup.ensure_worktree_removable(
|
|
979
|
+
str(worktree), str(main)
|
|
980
|
+
)
|
|
981
|
+
late_target = packages / "late@1.0.0/node_modules/late"
|
|
982
|
+
late_target.mkdir(parents=True)
|
|
983
|
+
late = worktree / "node_modules/late"
|
|
984
|
+
late.symlink_to(".pnpm/late@1.0.0/node_modules/late")
|
|
985
|
+
|
|
986
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
987
|
+
wrapup.remove_verified_worktree_scratch(
|
|
988
|
+
str(worktree), str(main), assessment
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
self.assertIn(
|
|
992
|
+
"inventory no longer matches preview", stopped.exception.reason
|
|
993
|
+
)
|
|
994
|
+
self.assertTrue(first.is_symlink())
|
|
995
|
+
self.assertTrue(late.is_symlink())
|
|
996
|
+
|
|
997
|
+
def test_changed_internal_symlink_target_identity_stops_cleanup(self):
|
|
998
|
+
wrapup = load_wrapup()
|
|
999
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1000
|
+
main, worktree = create_merged_worktree(
|
|
1001
|
+
Path(tmp), scratch_patterns=["PLAN.md"]
|
|
1002
|
+
)
|
|
1003
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1004
|
+
link = worktree / "PLAN.md"
|
|
1005
|
+
link.symlink_to("change.txt")
|
|
1006
|
+
assessment = wrapup.ensure_worktree_removable(
|
|
1007
|
+
str(worktree), str(main)
|
|
1008
|
+
)
|
|
1009
|
+
tracked_target = worktree / "change.txt"
|
|
1010
|
+
original = tracked_target.read_text(encoding="utf-8")
|
|
1011
|
+
replacement = worktree / "replacement.tmp"
|
|
1012
|
+
replacement.write_text(original, encoding="utf-8")
|
|
1013
|
+
os.replace(replacement, tracked_target)
|
|
1014
|
+
|
|
1015
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1016
|
+
wrapup.remove_verified_worktree_scratch(
|
|
1017
|
+
str(worktree), str(main), assessment
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
self.assertIn(
|
|
1021
|
+
"inventory no longer matches preview", stopped.exception.reason
|
|
1022
|
+
)
|
|
1023
|
+
self.assertTrue(link.is_symlink())
|
|
1024
|
+
self.assertEqual(tracked_target.read_text(encoding="utf-8"), original)
|
|
1025
|
+
|
|
937
1026
|
def test_same_path_generated_replacement_is_preserved_by_frozen_evidence(self):
|
|
938
1027
|
wrapup = load_wrapup()
|
|
939
1028
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -86,9 +86,14 @@ scratch is named in the report but does not block removal. The assessment reads
|
|
|
86
86
|
processes or removing the worktree.
|
|
87
87
|
|
|
88
88
|
Explicit removal re-collects facts immediately before mutation, requires the
|
|
89
|
-
same removable inventory, deletes only the exact contained
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
same removable inventory, deletes only the exact contained scratch entries
|
|
90
|
+
from that inventory, and uses ordinary `git worktree remove`. Regular-file
|
|
91
|
+
identity stays byte-bound. A profile-authorized symlink is eligible only when
|
|
92
|
+
its relative target resolves to an existing entry inside the assessed
|
|
93
|
+
worktree; cleanup freezes both the link and resolved target identities, then
|
|
94
|
+
unlinks the link without following it. Absolute, escaping, dangling, changed,
|
|
95
|
+
unmatched, or late symlinks stop cleanup. It never bypasses Git's final
|
|
96
|
+
concurrent-change check with force removal.
|
|
92
97
|
|
|
93
98
|
The generic setup route atomically records its ignored and complete
|
|
94
99
|
untracked-file inventories in the linked worktree's Git metadata after its
|
|
@@ -129,9 +134,10 @@ The landing adapter may carry exact scratch evidence only for current ignored
|
|
|
129
134
|
files that match the consumer-owned
|
|
130
135
|
`wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
|
|
131
136
|
creation baseline. Missing, changed, or incoherent provenance stops landing
|
|
132
|
-
cleanup. Initial/profile-matched files, unmatched files,
|
|
133
|
-
after the landing evidence snapshot remain cleanup stops;
|
|
134
|
-
|
|
137
|
+
cleanup. Initial/profile-matched files, unmatched files, landing-generated
|
|
138
|
+
symlinks, and writes after the landing evidence snapshot remain cleanup stops;
|
|
139
|
+
generator evidence and regular-file removal stay descriptor-bound and require
|
|
140
|
+
a second inventory check.
|
|
135
141
|
Mutable session logs belong in explicit `scratchPatterns`, not in the landing
|
|
136
142
|
generator allowlist: their identity is frozen at final cleanup assessment, so
|
|
137
143
|
normal logging before teardown remains live while a later append or replacement
|
|
@@ -1220,17 +1220,28 @@ def remove_authorized_scratch(
|
|
|
1220
1220
|
raise LifecycleError(
|
|
1221
1221
|
f"landing-generated scratch evidence is missing: {relative}"
|
|
1222
1222
|
)
|
|
1223
|
+
if generated and expected.get("kind") == "symlink":
|
|
1224
|
+
raise LifecycleError(
|
|
1225
|
+
f"landing-generated scratch path is not a regular file: {relative}"
|
|
1226
|
+
)
|
|
1223
1227
|
if profile_authorized and expected is None:
|
|
1224
1228
|
raise LifecycleError(f"profile scratch evidence is missing: {relative}")
|
|
1225
1229
|
if not profile_authorized and expected is None:
|
|
1226
1230
|
raise LifecycleError(f"verified scratch evidence is missing: {relative}")
|
|
1227
1231
|
for relative in scratch_files:
|
|
1228
1232
|
expected = evidence_by_path[relative]
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1233
|
+
if expected.get("kind") == "symlink":
|
|
1234
|
+
remove_contained_untracked(
|
|
1235
|
+
root_descriptor,
|
|
1236
|
+
expected,
|
|
1237
|
+
require_contained_symlink_target=True,
|
|
1238
|
+
)
|
|
1239
|
+
else:
|
|
1240
|
+
remove_contained_regular(
|
|
1241
|
+
root_descriptor,
|
|
1242
|
+
relative,
|
|
1243
|
+
expected_identity=expected,
|
|
1244
|
+
)
|
|
1234
1245
|
|
|
1235
1246
|
|
|
1236
1247
|
def bind_cleanup_scratch_evidence(
|
|
@@ -1292,7 +1303,7 @@ def bind_cleanup_scratch_evidence(
|
|
|
1292
1303
|
assessment.root_inode,
|
|
1293
1304
|
) as descriptor:
|
|
1294
1305
|
for relative in profile_only:
|
|
1295
|
-
evidence_by_path[relative] =
|
|
1306
|
+
evidence_by_path[relative] = contained_profile_scratch_identity(
|
|
1296
1307
|
descriptor, relative
|
|
1297
1308
|
)
|
|
1298
1309
|
return replace(
|
|
@@ -1366,6 +1377,8 @@ def contained_regular_identity(root_descriptor: int, relative: str) -> dict[str,
|
|
|
1366
1377
|
def contained_untracked_identity(
|
|
1367
1378
|
root_descriptor: int,
|
|
1368
1379
|
relative: str,
|
|
1380
|
+
*,
|
|
1381
|
+
require_contained_symlink_target: bool = False,
|
|
1369
1382
|
) -> dict[str, Any]:
|
|
1370
1383
|
"""Read exact regular/symlink identity without following any symlink."""
|
|
1371
1384
|
path = PurePosixPath(relative)
|
|
@@ -1393,10 +1406,26 @@ def contained_untracked_identity(
|
|
|
1393
1406
|
"size": metadata.st_size,
|
|
1394
1407
|
}
|
|
1395
1408
|
if stat.S_ISLNK(metadata.st_mode):
|
|
1396
|
-
|
|
1409
|
+
target_text = os.readlink(path.name, dir_fd=current)
|
|
1410
|
+
target = target_text.encode(
|
|
1397
1411
|
"utf-8", errors="surrogateescape"
|
|
1398
1412
|
)
|
|
1399
|
-
|
|
1413
|
+
identity = {
|
|
1414
|
+
**common,
|
|
1415
|
+
"kind": "symlink",
|
|
1416
|
+
"sha256": sha256(target).hexdigest(),
|
|
1417
|
+
}
|
|
1418
|
+
if require_contained_symlink_target:
|
|
1419
|
+
target_metadata = contained_symlink_target_identity(
|
|
1420
|
+
root_descriptor,
|
|
1421
|
+
relative,
|
|
1422
|
+
target_text,
|
|
1423
|
+
)
|
|
1424
|
+
identity.update({
|
|
1425
|
+
"targetDevice": target_metadata.st_dev,
|
|
1426
|
+
"targetInode": target_metadata.st_ino,
|
|
1427
|
+
})
|
|
1428
|
+
return identity
|
|
1400
1429
|
if not stat.S_ISREG(metadata.st_mode):
|
|
1401
1430
|
raise LifecycleError(f"unsupported recovery path type: {relative}")
|
|
1402
1431
|
file_descriptor = os.open(
|
|
@@ -1427,15 +1456,126 @@ def contained_untracked_identity(
|
|
|
1427
1456
|
os.close(descriptor)
|
|
1428
1457
|
|
|
1429
1458
|
|
|
1459
|
+
def contained_profile_scratch_identity(
|
|
1460
|
+
root_descriptor: int,
|
|
1461
|
+
relative: str,
|
|
1462
|
+
) -> dict[str, Any]:
|
|
1463
|
+
"""Freeze regular scratch unchanged, or one contained symlink and its target."""
|
|
1464
|
+
identity = contained_untracked_identity(
|
|
1465
|
+
root_descriptor,
|
|
1466
|
+
relative,
|
|
1467
|
+
require_contained_symlink_target=True,
|
|
1468
|
+
)
|
|
1469
|
+
if identity["kind"] == "regular":
|
|
1470
|
+
return {
|
|
1471
|
+
key: value
|
|
1472
|
+
for key, value in identity.items()
|
|
1473
|
+
if key != "kind"
|
|
1474
|
+
}
|
|
1475
|
+
return identity
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _relative_target_parts(
|
|
1479
|
+
base_parts: list[str],
|
|
1480
|
+
target: str,
|
|
1481
|
+
relative: str,
|
|
1482
|
+
) -> list[str]:
|
|
1483
|
+
target_path = PurePosixPath(target)
|
|
1484
|
+
if target_path.is_absolute():
|
|
1485
|
+
raise LifecycleError(f"scratch symlink target is absolute: {relative}")
|
|
1486
|
+
resolved = list(base_parts)
|
|
1487
|
+
for component in target_path.parts:
|
|
1488
|
+
if component in {"", "."}:
|
|
1489
|
+
continue
|
|
1490
|
+
if component == "..":
|
|
1491
|
+
if not resolved:
|
|
1492
|
+
raise LifecycleError(
|
|
1493
|
+
f"scratch symlink target escapes worktree: {relative}"
|
|
1494
|
+
)
|
|
1495
|
+
resolved.pop()
|
|
1496
|
+
continue
|
|
1497
|
+
resolved.append(component)
|
|
1498
|
+
return resolved
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def contained_symlink_target_identity(
|
|
1502
|
+
root_descriptor: int,
|
|
1503
|
+
relative: str,
|
|
1504
|
+
target: str,
|
|
1505
|
+
) -> os.stat_result:
|
|
1506
|
+
"""Resolve one symlink target beneath the open root, rejecting every escape."""
|
|
1507
|
+
link = PurePosixPath(relative)
|
|
1508
|
+
pending = _relative_target_parts(list(link.parts[:-1]), target, relative)
|
|
1509
|
+
resolved: list[str] = []
|
|
1510
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1511
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1512
|
+
symlink_count = 0
|
|
1513
|
+
try:
|
|
1514
|
+
if not pending:
|
|
1515
|
+
return os.fstat(root_descriptor)
|
|
1516
|
+
while pending:
|
|
1517
|
+
component = pending.pop(0)
|
|
1518
|
+
descriptors = []
|
|
1519
|
+
try:
|
|
1520
|
+
parent = root_descriptor
|
|
1521
|
+
for ancestor in resolved:
|
|
1522
|
+
parent = os.open(
|
|
1523
|
+
ancestor,
|
|
1524
|
+
directory_flags | no_follow,
|
|
1525
|
+
dir_fd=parent,
|
|
1526
|
+
)
|
|
1527
|
+
descriptors.append(parent)
|
|
1528
|
+
metadata = os.stat(
|
|
1529
|
+
component,
|
|
1530
|
+
dir_fd=parent,
|
|
1531
|
+
follow_symlinks=False,
|
|
1532
|
+
)
|
|
1533
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
1534
|
+
symlink_count += 1
|
|
1535
|
+
if symlink_count > 40:
|
|
1536
|
+
raise OSError("too many symbolic links")
|
|
1537
|
+
nested_target = os.readlink(component, dir_fd=parent)
|
|
1538
|
+
pending = _relative_target_parts(
|
|
1539
|
+
resolved,
|
|
1540
|
+
nested_target,
|
|
1541
|
+
relative,
|
|
1542
|
+
) + pending
|
|
1543
|
+
resolved = []
|
|
1544
|
+
if not pending:
|
|
1545
|
+
return os.fstat(root_descriptor)
|
|
1546
|
+
continue
|
|
1547
|
+
if pending and not stat.S_ISDIR(metadata.st_mode):
|
|
1548
|
+
raise OSError("symlink target parent is not a directory")
|
|
1549
|
+
resolved.append(component)
|
|
1550
|
+
if not pending:
|
|
1551
|
+
return metadata
|
|
1552
|
+
finally:
|
|
1553
|
+
for descriptor in reversed(descriptors):
|
|
1554
|
+
os.close(descriptor)
|
|
1555
|
+
except LifecycleError:
|
|
1556
|
+
raise
|
|
1557
|
+
except OSError as error:
|
|
1558
|
+
raise LifecycleError(
|
|
1559
|
+
f"scratch symlink target is dangling or changed: {relative}"
|
|
1560
|
+
) from error
|
|
1561
|
+
raise LifecycleError(f"scratch symlink target is dangling or changed: {relative}")
|
|
1562
|
+
|
|
1563
|
+
|
|
1430
1564
|
def remove_contained_untracked(
|
|
1431
1565
|
root_descriptor: int,
|
|
1432
1566
|
expected_identity: dict[str, Any],
|
|
1567
|
+
*,
|
|
1568
|
+
require_contained_symlink_target: bool = False,
|
|
1433
1569
|
) -> None:
|
|
1434
1570
|
"""Unlink one frozen regular file or symlink if its identity still matches."""
|
|
1435
1571
|
relative = expected_identity.get("path")
|
|
1436
1572
|
if not isinstance(relative, str):
|
|
1437
1573
|
raise LifecycleError("recovery path evidence is incoherent")
|
|
1438
|
-
current = contained_untracked_identity(
|
|
1574
|
+
current = contained_untracked_identity(
|
|
1575
|
+
root_descriptor,
|
|
1576
|
+
relative,
|
|
1577
|
+
require_contained_symlink_target=require_contained_symlink_target,
|
|
1578
|
+
)
|
|
1439
1579
|
if current != expected_identity:
|
|
1440
1580
|
raise LifecycleError(f"recovery path identity changed: {relative}")
|
|
1441
1581
|
path = PurePosixPath(relative)
|
package/docs/agents/domain.md
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
<!-- setup-workflow: state=filled -->
|
|
2
|
-
# Domain docs
|
|
3
|
-
|
|
4
|
-
This is a single-context repository. Before design work, read root `CONTEXT.md` when present and the relevant decisions under `docs/adr/`. Their absence is non-blocking: `grill-with-docs` creates them when the product language or a durable decision first needs recording.
|
|
5
|
-
|
|
6
|
-
Use glossary terms consistently in issues, tests, and skill text. Surface conflicts with an existing ADR instead of silently replacing its decision.
|
|
7
|
-
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
<!-- setup-workflow: state=filled -->
|
|
2
|
-
# Issue tracker: GitHub
|
|
3
|
-
|
|
4
|
-
Issues and PRDs for this repo live in `iKon85/agent-workflow-kit` as GitHub issues.
|
|
5
|
-
|
|
6
|
-
## Conventions
|
|
7
|
-
|
|
8
|
-
- Use `gh` for issue reads and comments; infer the repository from the remote.
|
|
9
|
-
- Write multi-line issue and PR bodies through a temporary file and `--body-file`.
|
|
10
|
-
- Route board writes, status/wave/cluster field edits, dependencies, and sub-issue links through `scripts/board-sync.py`, not bare `gh project` commands.
|
|
11
|
-
- Board state is authoritative for workflow status; GitHub issues remain the durable content source.
|
|
12
|
-
|
|
13
|
-
## Pickup claim
|
|
14
|
-
|
|
15
|
-
Used by `/implement`, `/diagnose`, and `/orchestrate-wave` before the first
|
|
16
|
-
edit. Board status `In Progress` is the human-facing signal; the claim below is
|
|
17
|
-
the machine-readable one, and it is the only one that names the branch and
|
|
18
|
-
worktree a colliding session would have to find.
|
|
19
|
-
|
|
20
|
-
- **Check**: `gh issue view <n> --json assignees,comments --jq '{assignees: [.assignees[].login], claims: [.comments[].body | select(contains("<!-- agent-claim:"))]}'` — a foreign assignee or a marker this session did not plant is a foreign claim: stop, report the claimed branch/worktree, and leave it alone.
|
|
21
|
-
- **Claim**: `gh issue edit <n> --add-assignee @me`, then `gh issue comment <n> --body '<!-- agent-claim: branch=<branch>; worktree=<absolute-path>; date=<YYYY-MM-DD> -->'`. Board status stays a `scripts/board-sync.py` write, never a bare `gh project` call.
|
|
22
|
-
- **Release**: the slice PR (`closes #<n>` / `Part of #<anchor>`) supersedes the claim, so `/wrapup` needs no extra step. On abandon: `gh issue edit <n> --remove-assignee @me` plus a `claim released` comment.
|
|
23
|
-
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
<!-- setup-workflow: state=filled -->
|
|
2
|
-
<!-- agent-workflow-kit: project-extension/v1; skill=local-ci -->
|
|
3
|
-
# Project layer — local-ci
|
|
4
|
-
|
|
5
|
-
This repo is a Node + stdlib-Python repo with no database, no dev server and no
|
|
6
|
-
typecheck step, so the two generic profiles map onto a short command set. Run the
|
|
7
|
-
two commands below; do not infer others.
|
|
8
|
-
|
|
9
|
-
Prerequisite once per clone (worktrees inherit it):
|
|
10
|
-
|
|
11
|
-
```sh
|
|
12
|
-
git config core.hooksPath .githooks
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Fast static guards
|
|
16
|
-
|
|
17
|
-
The skill/manifest lints, ~3s, no network, no DB. This is exactly what
|
|
18
|
-
`.githooks/pre-commit` runs, so a normal commit already covers it:
|
|
19
|
-
|
|
20
|
-
```sh
|
|
21
|
-
python3 -m unittest discover -s scripts -p 'test_skill_*.py' -q
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## Full gate
|
|
25
|
-
|
|
26
|
-
Mirrors the `test` job in `.github/workflows/ci.yml` step for step. Run it from
|
|
27
|
-
the branch's worktree root before opening a PR:
|
|
28
|
-
|
|
29
|
-
```sh
|
|
30
|
-
npm test
|
|
31
|
-
npm run kit:staleness
|
|
32
|
-
npm run release:guard -- --base "$(git merge-base origin/main HEAD)"
|
|
33
|
-
npm pack --dry-run
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
Notes on the individual steps:
|
|
37
|
-
|
|
38
|
-
- `npm test` is `test:node` (`node --test`) plus `test:python`
|
|
39
|
-
(`python3 -m unittest discover -s scripts -p 'test_*.py'`). Several negative-path
|
|
40
|
-
tests print `[FAIL] …` lines by design; only the runner's own summary and exit
|
|
41
|
-
code decide red or green.
|
|
42
|
-
- CI runs `npm install --ignore-scripts` first. Locally that is only needed after
|
|
43
|
-
a dependency change.
|
|
44
|
-
- `release:guard` runs in CI on `pull_request` only, against
|
|
45
|
-
`github.event.pull_request.base.sha`. `git merge-base origin/main HEAD` is the
|
|
46
|
-
local equivalent; `git fetch origin main` first if `origin/main` is stale.
|
|
47
|
-
- `npm pack --dry-run` catches packaging drift (a file added outside the
|
|
48
|
-
published set) without publishing anything.
|
|
49
|
-
|
|
50
|
-
## Enforcement
|
|
51
|
-
|
|
52
|
-
Since #220 the host **does** enforce: the `main protection` ruleset makes the CI
|
|
53
|
-
job `test` a required status check on `main`, with no bypass actor. So the
|
|
54
|
-
generic skill's "When the host CAN enforce" branch applies to this repo — the
|
|
55
|
-
local gate is a pre-flight that shortens the feedback loop, not the only thing
|
|
56
|
-
standing between a red branch and `main`.
|
|
57
|
-
|
|
58
|
-
Layered, from cheapest to strongest:
|
|
59
|
-
|
|
60
|
-
| Layer | Runs | Scope |
|
|
61
|
-
| --- | --- | --- |
|
|
62
|
-
| `pre-commit` | automatic, every commit | fast skill/manifest lints |
|
|
63
|
-
| `pre-push` | automatic, every push | full `npm test` |
|
|
64
|
-
| this full gate | explicit, before a PR | `npm test` + staleness + release guard + pack |
|
|
65
|
-
| CI `test` | automatic, on the PR | the same set, machine-enforced at merge |
|
|
66
|
-
|
|
67
|
-
Never bypass a hook with `--no-verify`.
|
|
68
|
-
|
|
69
|
-
**Drift rule.** The full gate above and `.github/workflows/ci.yml` are two copies
|
|
70
|
-
of one list. Change one, change the other in the same PR — otherwise the local
|
|
71
|
-
gate goes green on a set the required check does not accept.
|
|
72
|
-
|
|
73
|
-
## Contention
|
|
74
|
-
|
|
75
|
-
Not applicable here. This repo has no dev server and the suites are process-level
|
|
76
|
-
(`node --test`, `unittest`), so the generic boot-contention false-red guidance has
|
|
77
|
-
no target. A red is a real red until an isolated re-run of that one test proves
|
|
78
|
-
otherwise.
|
|
79
|
-
|
|
80
|
-
## On a red
|
|
81
|
-
|
|
82
|
-
1. Read the failing assertion — the guards print the exact drift (`file:line`,
|
|
83
|
-
the offending token, the missing manifest key).
|
|
84
|
-
2. Fix the source. A guard that legitimately cannot be satisfied yet gets a
|
|
85
|
-
documented allowlist entry with a reason; never widen a guard silently.
|
|
86
|
-
3. Re-run the single failing suite, then the full gate for sign-off.
|
|
87
|
-
|
|
88
|
-
`kit:staleness` red almost always means the generated kit artefacts lag the
|
|
89
|
-
skill sources — run `npm run kit:build` and commit the result rather than
|
|
90
|
-
editing the generated output by hand.
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
<!-- setup-workflow: state=filled -->
|
|
2
|
-
<!-- agent-workflow-kit: project-extension/v1; skill=orchestrate-wave -->
|
|
3
|
-
# Project layer — orchestrate-wave
|
|
4
|
-
|
|
5
|
-
Filled 2026-07-27 after Welle 18 (#289) ran the whole loop end-to-end — recon,
|
|
6
|
-
build, integrate, verify, land, tag, publish — and proved the recipe below. The
|
|
7
|
-
sections were deliberately empty until then; the generic fallback carried that
|
|
8
|
-
run and cost it a `degraded` start plus a hand-derivation of every command here.
|
|
9
|
-
|
|
10
|
-
This repo has **no database, no dev server, no typecheck step and no browser
|
|
11
|
-
surface**, so several generic phases collapse to very little. Where that is the
|
|
12
|
-
case the section says so rather than inventing a command.
|
|
13
|
-
|
|
14
|
-
## §Setup
|
|
15
|
-
|
|
16
|
-
Nothing to start. No DB, no tunnel, no service, no dev server — Phase 0's
|
|
17
|
-
"start what live-verify needs" is a no-op here.
|
|
18
|
-
|
|
19
|
-
Two things that are *not* no-ops:
|
|
20
|
-
|
|
21
|
-
- `git config core.hooksPath .githooks` once per clone (worktrees inherit it).
|
|
22
|
-
- `npm install` in the wave worktree after fast-forwarding to `origin/main` —
|
|
23
|
-
the lockfile is untracked, so dependencies do not travel with the branch.
|
|
24
|
-
|
|
25
|
-
## §Builder Commands
|
|
26
|
-
|
|
27
|
-
Per-slice gate, run by the implementer before reporting back:
|
|
28
|
-
|
|
29
|
-
```sh
|
|
30
|
-
npm test # test:node (node --test) + test:python (unittest)
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
The fast pre-commit lints run automatically via `.githooks/pre-commit`
|
|
34
|
-
(~3 s, skill/manifest lints). A slice is not "green" on the fast lints alone.
|
|
35
|
-
|
|
36
|
-
## §Builder Hard Rules
|
|
37
|
-
|
|
38
|
-
- **Never `--no-verify`.** pre-commit and pre-push are the backstop, not an
|
|
39
|
-
obstacle.
|
|
40
|
-
- **English-first prose** in every published skill; the language census in the
|
|
41
|
-
maintainer suite enforces it.
|
|
42
|
-
- **No hardcoded board values** — labels, headings, field IDs and status names
|
|
43
|
-
come from `docs/agents/board-sync.md`; the portability lint blocks literals.
|
|
44
|
-
- **Dual-surface mirror in the same PR.** A changed `.claude/skills/<s>/SKILL.md`
|
|
45
|
-
needs its `.agents/skills/<s>/SKILL.md` mirror via `codex-adapter-sync` in the
|
|
46
|
-
same PR, never after merge.
|
|
47
|
-
- **Shipped-file changes must preserve the manifest/reconcile contract.**
|
|
48
|
-
|
|
49
|
-
## §Integration Suites
|
|
50
|
-
|
|
51
|
-
Both frameworks, every time — `npm test` runs both and a red in either is a red:
|
|
52
|
-
|
|
53
|
-
```sh
|
|
54
|
-
npm test
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
- `test:node` — `node --test` (665 tests as of 0.42.0)
|
|
58
|
-
- `test:python` — `python3 -m unittest discover -s scripts -p 'test_*.py'`
|
|
59
|
-
(522 tests as of 0.42.0)
|
|
60
|
-
|
|
61
|
-
There is no typecheck step in this repo; do not look for one. Several
|
|
62
|
-
negative-path tests print `[FAIL] …` lines **by design** — only the runner's own
|
|
63
|
-
summary and exit code decide red or green.
|
|
64
|
-
|
|
65
|
-
## §Verify Recipe
|
|
66
|
-
|
|
67
|
-
Central verify is the `/local-ci` full gate, run from the wave worktree root.
|
|
68
|
-
It mirrors the CI `test` job step for step:
|
|
69
|
-
|
|
70
|
-
```sh
|
|
71
|
-
git fetch origin main
|
|
72
|
-
npm test
|
|
73
|
-
npm run kit:staleness
|
|
74
|
-
npm run release:guard -- --base "$(git merge-base origin/main HEAD)"
|
|
75
|
-
npm pack --dry-run
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
- **No branch-name-derived guard exists here**, so the generic skill's warning
|
|
79
|
-
about an integrated verify branch blocking with no baseline does not apply.
|
|
80
|
-
No skip or override is needed.
|
|
81
|
-
- **No browser, no headless login, no screenshots, no console check, no DB
|
|
82
|
-
compare, no design/brand check.** This repo ships text; "assert the AC as a
|
|
83
|
-
user-visible outcome" means reading the artefact the slice produced (an ADR,
|
|
84
|
-
a skill body, a script's output) and the tests that pin it.
|
|
85
|
-
- **`release:guard` red with "shipped delta has no version bump" is not a
|
|
86
|
-
failure of the wave** — it means the wave touched the shipped consumer set and
|
|
87
|
-
the release metadata is missing. See §Landing.
|
|
88
|
-
- **A flaky red is possible.** `scripts/codex-exec.test.mjs` contains
|
|
89
|
-
timing-sensitive tests that fail under CPU contention (#345). Re-run the single
|
|
90
|
-
test in isolation before treating it as a real red; a test that is green in
|
|
91
|
-
isolation and red under load is contention, not the wave.
|
|
92
|
-
|
|
93
|
-
## §Headless Login
|
|
94
|
-
|
|
95
|
-
Not applicable. No application, no browser surface, no authenticated session.
|
|
96
|
-
Never invent a login recipe for this repo.
|
|
97
|
-
|
|
98
|
-
## §Landing
|
|
99
|
-
|
|
100
|
-
**One PR for the whole wave**, base `main`. `main` is protected by the
|
|
101
|
-
`main protection` ruleset: a PR is required, the CI job **`test`** (job name, not
|
|
102
|
-
the workflow name `CI`) must be green with the branch up to date, and there are
|
|
103
|
-
no bypass actors — `gh pr merge --admin` does not help.
|
|
104
|
-
|
|
105
|
-
- `Closes #<n>` on its own line per leaf; `Part of #<anchor>` for the anchor,
|
|
106
|
-
never `closes`.
|
|
107
|
-
- Body via temp file + `--body-file`, and **run `gh` from the repo** (it resolves
|
|
108
|
-
the target repo from the cwd).
|
|
109
|
-
|
|
110
|
-
**Anchor reconcile, on PR create and on merge:**
|
|
111
|
-
|
|
112
|
-
```sh
|
|
113
|
-
python3 scripts/board-sync.py anchor-sync <anchor#> --dry-run # review first
|
|
114
|
-
python3 scripts/board-sync.py anchor-sync <anchor#>
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
`anchor-sync` only works on a **promoted** anchor whose body carries a slice
|
|
118
|
-
table between `<!-- slice-table:start -->` and `<!-- slice-table:end -->`. A
|
|
119
|
-
stub-shaped anchor (a bullet list of slices, `wave-stub` label) makes it a no-op
|
|
120
|
-
that exits 1 with "no slice table found — nothing to sync". That is a promotion
|
|
121
|
-
gap, not a sync failure: install the table with the columns
|
|
122
|
-
`| # | Status | Slice | Sub-Issue | Gate | closes/refs |`, drop the `wave-stub`
|
|
123
|
-
label, then re-run. Do not hand-maintain the Status cells afterwards — they are
|
|
124
|
-
regenerated from the board.
|
|
125
|
-
|
|
126
|
-
**Completion, after merge:**
|
|
127
|
-
|
|
128
|
-
```sh
|
|
129
|
-
python3 scripts/board-sync.py add --issue <anchor#> --status-role done
|
|
130
|
-
gh issue close <anchor#> --reason completed --comment "…"
|
|
131
|
-
python3 scripts/board-sync.py item-of --issue <anchor#> # re-read: status must be Done
|
|
132
|
-
python3 scripts/board-sync.py parent-of <anchor#> # Program-PRD?
|
|
133
|
-
python3 scripts/board-sync.py program-sync <prd#> # after the anchor is Done
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Closing the issue does **not** move the board field — set the field, then re-read
|
|
137
|
-
both.
|
|
138
|
-
|
|
139
|
-
**New issues** go through `python3 scripts/board-sync.py create --title … --body-file …`,
|
|
140
|
-
never a bare `gh issue create`.
|
|
141
|
-
|
|
142
|
-
### Release lockstep
|
|
143
|
-
|
|
144
|
-
There is no deploy and no migration, but there **is** a publication gate, and a
|
|
145
|
-
wave that touches the shipped consumer set (anything in the kit manifest —
|
|
146
|
-
including `docs/adr/*` and `docs/research/*`) drags it along.
|
|
147
|
-
|
|
148
|
-
1. `npm run release:prepare -- --base origin/main` reports the delta and a
|
|
149
|
-
`recommendedBump` without writing anything.
|
|
150
|
-
2. **The confirmed Semver is the one human gate.** Ask, unless an explicit
|
|
151
|
-
end-to-end mandate covers release preparation. That confirmed version then
|
|
152
|
-
authorizes metadata, merge, tag *and* publish — do not ask again after merge.
|
|
153
|
-
3. `npm run release:prepare -- --version <x.y.z>`, commit the bump.
|
|
154
|
-
4. Merge the PR. Merging **integrates only**; it cannot publish.
|
|
155
|
-
5. Verify on canonical `main`: tip == merge commit, `package.json` version
|
|
156
|
-
matches, no existing tag. Then `git tag -a v<x.y.z> <main-tip> -m "…"` and
|
|
157
|
-
push. The annotated tag on the `main` tip is the sole publication intent.
|
|
158
|
-
6. **A red release run does not prove nothing was published.** Read
|
|
159
|
-
`npm view @ikon85/agent-workflow-kit version` and `gh release view v<x.y.z>`
|
|
160
|
-
directly, plus `npm run release:status` (cache-bypassing), before reacting.
|
|
161
|
-
7. **Recovery is `gh workflow run release.yml -f tag=v<x.y.z>` on the existing
|
|
162
|
-
tag** — the reconciler is idempotent. **Never recover by bumping the version.**
|
|
163
|
-
|
|
164
|
-
The release workflow runs the full suite *after* the tag is pushed, so a flaky
|
|
165
|
-
test strands an already-published tag. That is the recovery path above, not a
|
|
166
|
-
reason to re-tag.
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
<!-- setup-workflow: state=filled -->
|
|
2
|
-
<!-- agent-workflow-kit: project-extension/v1; skill=spec-self-critique -->
|
|
3
|
-
# Project layer — spec-self-critique
|
|
4
|
-
|
|
5
|
-
The generic checklist owns the checks; `/retro` appends project-specific incidents below these stable anchors.
|
|
6
|
-
|
|
7
|
-
## Point 1
|
|
8
|
-
## Point 2
|
|
9
|
-
## Point 3
|
|
10
|
-
## Point 4
|
|
11
|
-
## Point 5
|
|
12
|
-
## Point 6
|
|
13
|
-
## Point 7
|
|
14
|
-
## Point 8
|
|
15
|
-
## Point 9
|
|
16
|
-
## Point 10
|
|
17
|
-
## Point 11
|
|
18
|
-
## Point 12
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
<!-- setup-workflow: state=filled -->
|
|
2
|
-
# Triage labels
|
|
3
|
-
|
|
4
|
-
| Canonical role | Label in this repository | Meaning |
|
|
5
|
-
|---|---|---|
|
|
6
|
-
| `needs-info` | `needs-info` | Waiting for information from the reporter |
|
|
7
|
-
| `ready-for-agent` | `ready-for-agent` | Fully specified and safe for an AFK agent |
|
|
8
|
-
|
|
9
|
-
The board Status field owns `Idea`, `Triaged`, `Spec`, `In Progress`, `Review`, and `Done`. Close rejected work instead of adding a separate `wontfix` workflow label.
|
|
10
|
-
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
<!-- setup-workflow: state=filled -->
|
|
2
|
-
# Convention — Spec completeness before implementation
|
|
3
|
-
|
|
4
|
-
## Self-Critique-Check
|
|
5
|
-
|
|
6
|
-
**Trigger:** A spec states a concrete count, quantity, or `N of M` claim.
|
|
7
|
-
|
|
8
|
-
**Check:** Is each number freshly derived from the source rather than estimated or recalled?
|
|
9
|
-
|
|
10
|
-
**Korrektur:** Recount from code and replace the claim, or mark it explicitly as an implementation-time assumption.
|
|
11
|
-
|
|
12
|
-
## Vertical-slice completeness
|
|
13
|
-
|
|
14
|
-
Every user-facing slice is an outcome tracer, carries at least one acceptance criterion, names its estimated blast radius, and identifies seam ownership. Every prep slice names the omitted half and the later slice that closes it. `to-issues` must re-derive this readiness rather than trusting a pre-cut table.
|
|
15
|
-
|
|
16
|
-
## Self-Critique-Check
|
|
17
|
-
|
|
18
|
-
**Trigger:** A spec contains a wave/slice table or decomposes into multiple issues.
|
|
19
|
-
|
|
20
|
-
**Check:** Are rows outcomes rather than layer names, and do enablers name the outcome wave that closes them?
|
|
21
|
-
|
|
22
|
-
**Korrektur:** Reword or split layer-only rows and make every deferred half explicit.
|
|
23
|
-
|