@ikon85/agent-workflow-kit 0.36.5 → 0.38.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/orchestrate-wave/SKILL.md +20 -12
- package/.agents/skills/setup-workflow/SKILL.md +24 -3
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.agents/skills/wrapup/SKILL.md +42 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
- package/.claude/skills/setup-workflow/SKILL.md +24 -3
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +33 -10
- package/README.md +88 -1
- package/agent-workflow-kit.package.json +36 -28
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +44 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_worktree_wrapup_contract.py +1004 -3
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +126 -4
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +143 -5
- package/scripts/worktree-lifecycle/core.py +1383 -20
- package/scripts/worktree-lifecycle/profile.py +40 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +650 -27
- package/src/cli.mjs +60 -27
- package/src/lib/bundle.mjs +1 -0
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +3 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { inspectProjectSkillExtension } from '../src/lib/projectSkillExtension.mjs';
|
|
6
|
+
import { readComposedSkillRegistry } from '../src/lib/skillRegistry.mjs';
|
|
7
|
+
|
|
8
|
+
const CORE_REGISTRY = '.claude/skills/skill-manifest.json';
|
|
5
9
|
|
|
6
10
|
function argumentsFor(argv) {
|
|
7
11
|
const args = [...argv];
|
|
@@ -23,7 +27,22 @@ function argumentsFor(argv) {
|
|
|
23
27
|
|
|
24
28
|
export async function main(argv = process.argv.slice(2)) {
|
|
25
29
|
const options = argumentsFor(argv);
|
|
26
|
-
|
|
30
|
+
let activation;
|
|
31
|
+
try {
|
|
32
|
+
const core = JSON.parse(await readFile(join(options.root, CORE_REGISTRY), 'utf8'));
|
|
33
|
+
const registry = await readComposedSkillRegistry(options.root, core);
|
|
34
|
+
const matches = Object.values(registry.readiness?.capabilities ?? {})
|
|
35
|
+
.map((capability) => capability?.evidence)
|
|
36
|
+
.filter((evidence) =>
|
|
37
|
+
evidence?.type === 'project-extension' && evidence.skill === options.skill);
|
|
38
|
+
if (matches.length > 1) {
|
|
39
|
+
throw new Error(`multiple Project extension activation policies for ${options.skill}`);
|
|
40
|
+
}
|
|
41
|
+
activation = matches[0]?.activation;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code !== 'ENOENT') throw error;
|
|
44
|
+
}
|
|
45
|
+
return inspectProjectSkillExtension({ ...options, activation });
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
package/scripts/readiness.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { firstLineState } from '../src/lib/sentinel.mjs';
|
|
6
6
|
import { CONSUMER_MANIFEST_NAME, readManifest, writeManifest } from '../src/lib/manifest.mjs';
|
|
7
|
+
import { inspectProjectSkillExtension } from '../src/lib/projectSkillExtension.mjs';
|
|
7
8
|
import { readComposedSkillRegistry } from '../src/lib/skillRegistry.mjs';
|
|
8
9
|
|
|
9
10
|
const SOURCE_MANIFEST = '.claude/skills/skill-manifest.json';
|
|
@@ -87,6 +88,19 @@ async function runbookVerdict(root, evidence) {
|
|
|
87
88
|
return 'invalid';
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
async function projectExtensionVerdict(root, evidence) {
|
|
92
|
+
try {
|
|
93
|
+
const result = await inspectProjectSkillExtension({
|
|
94
|
+
root,
|
|
95
|
+
skill: evidence.skill,
|
|
96
|
+
activation: evidence.activation,
|
|
97
|
+
});
|
|
98
|
+
return { verdict: result.state === 'active' ? 'valid' : 'absent' };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return { verdict: 'invalid', blocking: true, diagnostic: error.message };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
function section(text, heading) {
|
|
91
105
|
if (text === null) return null;
|
|
92
106
|
const lines = text.split('\n');
|
|
@@ -148,6 +162,7 @@ async function prodEvidence(root, paths) {
|
|
|
148
162
|
async function evidenceVerdict(root, evidence) {
|
|
149
163
|
if (evidence.type === 'prod-section') return (await prodEvidence(root, evidence.paths)).verdict;
|
|
150
164
|
if (evidence.type === 'runbook-reference') return runbookVerdict(root, evidence);
|
|
165
|
+
if (evidence.type === 'project-extension') return projectExtensionVerdict(root, evidence);
|
|
151
166
|
const verdicts = await Promise.all((evidence.paths ?? []).map(async (path) => {
|
|
152
167
|
const text = await readText(root, path);
|
|
153
168
|
if (evidence.type === 'sentinel') return sentinelVerdict(text, evidence.allowLegacy);
|
|
@@ -163,9 +178,19 @@ async function evidenceVerdict(root, evidence) {
|
|
|
163
178
|
export async function evaluateCapability({ root, capability, decision }) {
|
|
164
179
|
const prod = capability.evidence.type === 'prod-section'
|
|
165
180
|
? await prodEvidence(root, capability.evidence.paths) : null;
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
181
|
+
const evaluated = prod ?? await evidenceVerdict(root, capability.evidence);
|
|
182
|
+
const evidence = typeof evaluated === 'string' ? evaluated : evaluated.verdict;
|
|
183
|
+
const diagnostics = evaluated?.diagnostics?.length
|
|
184
|
+
? { diagnostics: evaluated.diagnostics }
|
|
185
|
+
: (evaluated?.diagnostic ? { diagnostic: evaluated.diagnostic } : {});
|
|
186
|
+
if (evidence === 'invalid') {
|
|
187
|
+
return {
|
|
188
|
+
state: 'invalid',
|
|
189
|
+
clearDecision: false,
|
|
190
|
+
...(evaluated?.blocking ? { blocking: true } : {}),
|
|
191
|
+
...diagnostics,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
169
194
|
if (evidence === 'valid') return { state: 'ready', clearDecision: Boolean(decision) };
|
|
170
195
|
if (decision === 'pending') return { state: 'pending', clearDecision: false, ...diagnostics };
|
|
171
196
|
if (decision === 'not-applicable' && capability.allowNotApplicable) {
|
|
@@ -206,7 +231,10 @@ export async function checkSkill({ root, skill, manifest }) {
|
|
|
206
231
|
(capabilities[name].state === 'ready' ? activeBlocks : inactiveBlocks).push(block);
|
|
207
232
|
}
|
|
208
233
|
const invalid = Object.values(capabilities).some(({ state }) => state === 'invalid');
|
|
209
|
-
const
|
|
234
|
+
const fatal = Object.values(capabilities).some(({ blocking }) => blocking === true);
|
|
235
|
+
const verdict = requiredBlocked || fatal
|
|
236
|
+
? 'blocked'
|
|
237
|
+
: (inactiveBlocks.length || invalid ? 'degraded' : 'ready');
|
|
210
238
|
return { contractVersion: manifest.readiness.contractVersion, verdict, capabilities, activeBlocks, inactiveBlocks };
|
|
211
239
|
}
|
|
212
240
|
|
|
@@ -8,11 +8,21 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { assertReleaseParity, releaseIdentityFromTarball } from './release-parity.mjs';
|
|
9
9
|
|
|
10
10
|
const exec = promisify(execFile);
|
|
11
|
+
export const DEFAULT_VISIBILITY_DELAYS_MS = Object.freeze([
|
|
12
|
+
1_000,
|
|
13
|
+
2_000,
|
|
14
|
+
4_000,
|
|
15
|
+
8_000,
|
|
16
|
+
16_000,
|
|
17
|
+
32_000,
|
|
18
|
+
64_000,
|
|
19
|
+
128_000,
|
|
20
|
+
256_000,
|
|
21
|
+
]);
|
|
11
22
|
const DEFAULT_VISIBILITY = {
|
|
12
|
-
|
|
13
|
-
initialDelayMs: 1_000,
|
|
14
|
-
backoffFactor: 2,
|
|
23
|
+
delaysMs: DEFAULT_VISIBILITY_DELAYS_MS,
|
|
15
24
|
sleep,
|
|
25
|
+
log: console.log,
|
|
16
26
|
};
|
|
17
27
|
|
|
18
28
|
const assertMatches = (local, remote, label) => assertReleaseParity({
|
|
@@ -37,18 +47,18 @@ export async function inspectRelease(adapter) {
|
|
|
37
47
|
|
|
38
48
|
async function awaitVisibility(read, phase, options = {}) {
|
|
39
49
|
const policy = { ...DEFAULT_VISIBILITY, ...options };
|
|
40
|
-
|
|
41
|
-
for (let attempt = 1; attempt <=
|
|
50
|
+
const attempts = policy.delaysMs.length + 1;
|
|
51
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
52
|
+
policy.log(`${phase.service} visibility: attempt ${attempt}/${attempts}`);
|
|
42
53
|
const visible = await read();
|
|
43
54
|
if (visible) return visible;
|
|
44
|
-
if (attempt <
|
|
45
|
-
await policy.sleep(
|
|
46
|
-
delay *= policy.backoffFactor;
|
|
55
|
+
if (attempt < attempts) {
|
|
56
|
+
await policy.sleep(policy.delaysMs[attempt - 1]);
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
throw new Error(
|
|
50
60
|
`${phase.operation} succeeded but ${phase.subject} was not visible `
|
|
51
|
-
+ `after ${
|
|
61
|
+
+ `after ${attempts} ${phase.service} read attempts`,
|
|
52
62
|
);
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -7,8 +7,8 @@ import { dirname, join } from 'node:path';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import {
|
|
10
|
-
createCommandAdapter, githubReleaseArgs, inspectRelease,
|
|
11
|
-
npmTarballFilename, reconcileRelease,
|
|
10
|
+
createCommandAdapter, DEFAULT_VISIBILITY_DELAYS_MS, githubReleaseArgs, inspectRelease,
|
|
11
|
+
isMissingRelease, npmTarballFilename, reconcileRelease,
|
|
12
12
|
} from './release-state.mjs';
|
|
13
13
|
|
|
14
14
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -26,6 +26,7 @@ function adapter({
|
|
|
26
26
|
githubInvisibleReads = 0,
|
|
27
27
|
publishNpmError,
|
|
28
28
|
createGithubError,
|
|
29
|
+
npmReadErrorAfterPublish,
|
|
29
30
|
} = {}) {
|
|
30
31
|
const events = [];
|
|
31
32
|
let npmExists = npmPublished;
|
|
@@ -38,6 +39,7 @@ function adapter({
|
|
|
38
39
|
npm: async () => {
|
|
39
40
|
events.push('read npm');
|
|
40
41
|
if (!npmExists) return null;
|
|
42
|
+
if (npmReadErrorAfterPublish) throw npmReadErrorAfterPublish;
|
|
41
43
|
if (npmReadsUntilVisible > 0) {
|
|
42
44
|
npmReadsUntilVisible -= 1;
|
|
43
45
|
return null;
|
|
@@ -66,17 +68,25 @@ function adapter({
|
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
function visibilityOptions(attempts, delays = []) {
|
|
71
|
+
function visibilityOptions(attempts, delays = [], progress = []) {
|
|
70
72
|
return {
|
|
71
73
|
visibility: {
|
|
72
|
-
attempts,
|
|
73
|
-
initialDelayMs: 10,
|
|
74
|
-
backoffFactor: 2,
|
|
74
|
+
delaysMs: Array.from({ length: attempts - 1 }, (_, index) => 10 * (2 ** index)),
|
|
75
75
|
sleep: async (delay) => delays.push(delay),
|
|
76
|
+
log: (message) => progress.push(message),
|
|
76
77
|
},
|
|
77
78
|
};
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
test('the default npm visibility window spans at least five minutes', () => {
|
|
82
|
+
assert.ok(
|
|
83
|
+
DEFAULT_VISIBILITY_DELAYS_MS.reduce((total, delay) => total + delay, 0) >= 300_000,
|
|
84
|
+
);
|
|
85
|
+
assert.deepEqual(DEFAULT_VISIBILITY_DELAYS_MS, [
|
|
86
|
+
1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 64_000, 128_000, 256_000,
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
80
90
|
test('a fresh release publishes npm, verifies registry readback, then creates GitHub release', async () => {
|
|
81
91
|
const fixture = adapter();
|
|
82
92
|
assert.deepEqual(await reconcileRelease(fixture), { status: 'released', identity });
|
|
@@ -88,8 +98,9 @@ test('a fresh release publishes npm, verifies registry readback, then creates Gi
|
|
|
88
98
|
test('npm visibility is retried with bounded backoff after a successful publish', async () => {
|
|
89
99
|
const fixture = adapter({ npmInvisibleReads: 2 });
|
|
90
100
|
const delays = [];
|
|
101
|
+
const progress = [];
|
|
91
102
|
assert.deepEqual(
|
|
92
|
-
await reconcileRelease(fixture, visibilityOptions(4, delays)),
|
|
103
|
+
await reconcileRelease(fixture, visibilityOptions(4, delays, progress)),
|
|
93
104
|
{ status: 'released', identity },
|
|
94
105
|
);
|
|
95
106
|
assert.deepEqual(fixture.events, [
|
|
@@ -98,6 +109,12 @@ test('npm visibility is retried with bounded backoff after a successful publish'
|
|
|
98
109
|
'create github', 'read github',
|
|
99
110
|
]);
|
|
100
111
|
assert.deepEqual(delays, [10, 20]);
|
|
112
|
+
assert.deepEqual(progress, [
|
|
113
|
+
'npm visibility: attempt 1/4',
|
|
114
|
+
'npm visibility: attempt 2/4',
|
|
115
|
+
'npm visibility: attempt 3/4',
|
|
116
|
+
'GitHub visibility: attempt 1/4',
|
|
117
|
+
]);
|
|
101
118
|
});
|
|
102
119
|
|
|
103
120
|
test('GitHub visibility is retried with bounded backoff after a successful create', async () => {
|
|
@@ -116,11 +133,17 @@ test('GitHub visibility is retried with bounded backoff after a successful creat
|
|
|
116
133
|
test('permanent npm absence fails at the bound with post-publish phase evidence', async () => {
|
|
117
134
|
const fixture = adapter({ npmInvisibleReads: 4 });
|
|
118
135
|
const delays = [];
|
|
136
|
+
const progress = [];
|
|
119
137
|
await assert.rejects(
|
|
120
|
-
reconcileRelease(fixture, visibilityOptions(3, delays)),
|
|
138
|
+
reconcileRelease(fixture, visibilityOptions(3, delays, progress)),
|
|
121
139
|
/npm publish succeeded but package was not visible after 3 npm read attempts/,
|
|
122
140
|
);
|
|
123
141
|
assert.deepEqual(delays, [10, 20]);
|
|
142
|
+
assert.deepEqual(progress, [
|
|
143
|
+
'npm visibility: attempt 1/3',
|
|
144
|
+
'npm visibility: attempt 2/3',
|
|
145
|
+
'npm visibility: attempt 3/3',
|
|
146
|
+
]);
|
|
124
147
|
assert.equal(fixture.events.filter((event) => event === 'read npm').length, 4);
|
|
125
148
|
assert.ok(!fixture.events.includes('create github'));
|
|
126
149
|
});
|
|
@@ -158,6 +181,14 @@ test('an npm-published release resumes at GitHub without a second npm publish',
|
|
|
158
181
|
assert.ok(!fixture.events.includes('publish npm'));
|
|
159
182
|
});
|
|
160
183
|
|
|
184
|
+
test('a rerun after eventual npm visibility never publishes the version twice', async () => {
|
|
185
|
+
const fixture = adapter({ npmInvisibleReads: 2 });
|
|
186
|
+
await reconcileRelease(fixture, visibilityOptions(4));
|
|
187
|
+
await reconcileRelease(fixture, visibilityOptions(4));
|
|
188
|
+
assert.equal(fixture.events.filter((event) => event === 'publish npm').length, 1);
|
|
189
|
+
assert.equal(fixture.events.filter((event) => event === 'create github').length, 1);
|
|
190
|
+
});
|
|
191
|
+
|
|
161
192
|
test('an already-published release at parity is a read-only no-op', async () => {
|
|
162
193
|
const fixture = adapter({ npmPublished: true, githubReleased: true });
|
|
163
194
|
assert.deepEqual(await reconcileRelease(fixture), { status: 'released', identity });
|
|
@@ -256,6 +287,38 @@ test('a mismatching npm package blocks GitHub release creation', async () => {
|
|
|
256
287
|
assert.ok(!fixture.events.includes('create github'));
|
|
257
288
|
});
|
|
258
289
|
|
|
290
|
+
test('a post-publish npm mismatch hard-stops without retrying or creating GitHub', async () => {
|
|
291
|
+
const fixture = adapter({
|
|
292
|
+
npmIdentity: { ...identity, manifestSha256: 'wrong' },
|
|
293
|
+
});
|
|
294
|
+
const delays = [];
|
|
295
|
+
const progress = [];
|
|
296
|
+
await assert.rejects(
|
|
297
|
+
reconcileRelease(fixture, visibilityOptions(4, delays, progress)),
|
|
298
|
+
/npm manifestSha256 mismatch/,
|
|
299
|
+
);
|
|
300
|
+
assert.deepEqual(delays, []);
|
|
301
|
+
assert.deepEqual(progress, ['npm visibility: attempt 1/4']);
|
|
302
|
+
assert.equal(fixture.events.filter((event) => event === 'publish npm').length, 1);
|
|
303
|
+
assert.equal(fixture.events.filter((event) => event === 'read npm').length, 2);
|
|
304
|
+
assert.ok(!fixture.events.includes('create github'));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('a post-publish non-missing registry error hard-stops without retrying', async () => {
|
|
308
|
+
const registryError = new Error('registry authentication failed');
|
|
309
|
+
const fixture = adapter({ npmReadErrorAfterPublish: registryError });
|
|
310
|
+
const delays = [];
|
|
311
|
+
const progress = [];
|
|
312
|
+
await assert.rejects(
|
|
313
|
+
reconcileRelease(fixture, visibilityOptions(4, delays, progress)),
|
|
314
|
+
(error) => error === registryError,
|
|
315
|
+
);
|
|
316
|
+
assert.deepEqual(delays, []);
|
|
317
|
+
assert.deepEqual(progress, ['npm visibility: attempt 1/4']);
|
|
318
|
+
assert.equal(fixture.events.filter((event) => event === 'publish npm').length, 1);
|
|
319
|
+
assert.ok(!fixture.events.includes('create github'));
|
|
320
|
+
});
|
|
321
|
+
|
|
259
322
|
test('release workflow uses supported OIDC trusted publishing without a long-lived npm token', async () => {
|
|
260
323
|
const workflow = await readFile(join(REPO, '.github/workflows/release.yml'), 'utf8');
|
|
261
324
|
assert.match(workflow, /node-version:\s*22\.14/);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused regression tests for pure anchor-table status decisions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from scripts.anchor_table import refresh_status_cell, status_token_from_board
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ROLES = {
|
|
12
|
+
"inProgress": "In Progress",
|
|
13
|
+
"review": "In Review",
|
|
14
|
+
"done": "Done",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnchorTableStatusTest(unittest.TestCase):
|
|
19
|
+
def test_closed_done_issue_without_pr_is_done(self):
|
|
20
|
+
entry = {"state": "closed", "status": "Done", "prs": []}
|
|
21
|
+
|
|
22
|
+
self.assertEqual(status_token_from_board(entry, ROLES), "✅")
|
|
23
|
+
|
|
24
|
+
def test_open_issue_without_pr_is_never_done(self):
|
|
25
|
+
cases = (
|
|
26
|
+
({"state": "open", "status": "Done", "prs": []}, "⬜"),
|
|
27
|
+
({"state": "open", "status": "In Progress", "prs": []}, "🔄"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
for entry, expected in cases:
|
|
31
|
+
with self.subTest(status=entry["status"]):
|
|
32
|
+
self.assertEqual(status_token_from_board(entry, ROLES), expected)
|
|
33
|
+
|
|
34
|
+
def test_merged_pr_keeps_numbered_done_token(self):
|
|
35
|
+
entry = {
|
|
36
|
+
"state": "closed",
|
|
37
|
+
"status": "Done",
|
|
38
|
+
"prs": [{"state": "MERGED", "number": 203}],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
self.assertEqual(status_token_from_board(entry, ROLES), "✅ #203")
|
|
42
|
+
|
|
43
|
+
def test_refresh_never_regresses_existing_progress(self):
|
|
44
|
+
open_unstarted = {"state": "open", "status": "To Do", "prs": []}
|
|
45
|
+
|
|
46
|
+
self.assertEqual(
|
|
47
|
+
refresh_status_cell("✅ spike proven", open_unstarted, ROLES),
|
|
48
|
+
"✅ spike proven",
|
|
49
|
+
)
|
|
50
|
+
self.assertEqual(
|
|
51
|
+
refresh_status_cell("🔄 spike running", open_unstarted, ROLES),
|
|
52
|
+
"🔄 spike running",
|
|
53
|
+
)
|
|
54
|
+
self.assertEqual(
|
|
55
|
+
refresh_status_cell("✅ #199", open_unstarted, ROLES),
|
|
56
|
+
"✅ #199",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def test_refresh_advances_to_prless_done(self):
|
|
60
|
+
closed_done = {"state": "closed", "status": "Done", "prs": []}
|
|
61
|
+
|
|
62
|
+
self.assertEqual(
|
|
63
|
+
refresh_status_cell("🔄 spike running", closed_done, ROLES),
|
|
64
|
+
"✅",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
unittest.main()
|
|
@@ -34,6 +34,7 @@ PROFILE = {
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def load_board_sync():
|
|
37
|
+
previous_profile = os.environ.get("BOARD_SYNC_PROFILE")
|
|
37
38
|
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
|
|
38
39
|
profile.write("<!-- board-sync:profile -->\n```json\n")
|
|
39
40
|
json.dump(PROFILE, profile)
|
|
@@ -45,11 +46,18 @@ def load_board_sync():
|
|
|
45
46
|
)
|
|
46
47
|
module = importlib.util.module_from_spec(spec)
|
|
47
48
|
assert spec.loader is not None
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
try:
|
|
50
|
+
spec.loader.exec_module(module)
|
|
51
|
+
finally:
|
|
52
|
+
Path(profile_path).unlink()
|
|
53
|
+
if previous_profile is None:
|
|
54
|
+
os.environ.pop("BOARD_SYNC_PROFILE", None)
|
|
55
|
+
else:
|
|
56
|
+
os.environ["BOARD_SYNC_PROFILE"] = previous_profile
|
|
50
57
|
return module
|
|
51
58
|
|
|
52
59
|
|
|
60
|
+
PROFILE_BEFORE_LOAD = os.environ.get("BOARD_SYNC_PROFILE")
|
|
53
61
|
bs = load_board_sync()
|
|
54
62
|
|
|
55
63
|
|
|
@@ -80,6 +88,11 @@ class FakeGh:
|
|
|
80
88
|
return ""
|
|
81
89
|
|
|
82
90
|
|
|
91
|
+
class BoardSyncImportIsolationTest(unittest.TestCase):
|
|
92
|
+
def test_loading_board_sync_restores_the_caller_profile(self):
|
|
93
|
+
self.assertEqual(os.environ.get("BOARD_SYNC_PROFILE"), PROFILE_BEFORE_LOAD)
|
|
94
|
+
|
|
95
|
+
|
|
83
96
|
class MarkerAwareCreateTest(unittest.TestCase):
|
|
84
97
|
def test_identical_program_leaf_retry_reuses_existing_issue(self):
|
|
85
98
|
marker = "<!-- program-leaf-source: program-x/1a -->"
|
|
@@ -37,6 +37,7 @@ BASE_PROFILE = {
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def load_board_sync(profile_dict: dict, module_name: str):
|
|
40
|
+
previous_profile = os.environ.get("BOARD_SYNC_PROFILE")
|
|
40
41
|
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
|
|
41
42
|
profile.write("<!-- board-sync:profile -->\n```json\n")
|
|
42
43
|
json.dump(profile_dict, profile)
|
|
@@ -48,8 +49,14 @@ def load_board_sync(profile_dict: dict, module_name: str):
|
|
|
48
49
|
)
|
|
49
50
|
module = importlib.util.module_from_spec(spec)
|
|
50
51
|
assert spec.loader is not None
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
try:
|
|
53
|
+
spec.loader.exec_module(module)
|
|
54
|
+
finally:
|
|
55
|
+
Path(profile_path).unlink()
|
|
56
|
+
if previous_profile is None:
|
|
57
|
+
os.environ.pop("BOARD_SYNC_PROFILE", None)
|
|
58
|
+
else:
|
|
59
|
+
os.environ["BOARD_SYNC_PROFILE"] = previous_profile
|
|
53
60
|
return module
|
|
54
61
|
|
|
55
62
|
|
|
@@ -75,6 +82,11 @@ class WaveTitleDefaultPrefix(unittest.TestCase):
|
|
|
75
82
|
"Welle 29 — Auth hardening",
|
|
76
83
|
)
|
|
77
84
|
|
|
85
|
+
def test_loading_board_sync_restores_the_caller_profile(self):
|
|
86
|
+
before = os.environ.get("BOARD_SYNC_PROFILE")
|
|
87
|
+
load_board_sync(copy.deepcopy(BASE_PROFILE), "board_sync_wave_isolation")
|
|
88
|
+
self.assertEqual(os.environ.get("BOARD_SYNC_PROFILE"), before)
|
|
89
|
+
|
|
78
90
|
|
|
79
91
|
class WaveTitleProfilePrefix(unittest.TestCase):
|
|
80
92
|
"""A profile with `titles.wavePrefix` drives the anchor title language."""
|
|
@@ -40,6 +40,39 @@ class CensusBackstopTest(unittest.TestCase):
|
|
|
40
40
|
"overrides": overrides or [],
|
|
41
41
|
}) + "\n", encoding="utf-8")
|
|
42
42
|
|
|
43
|
+
def test_execute_ready_missing_profile_failure_is_captured_and_exact(self):
|
|
44
|
+
missing_profile = ROOT / "missing-board-profile.md"
|
|
45
|
+
environment = os.environ.copy()
|
|
46
|
+
environment["BOARD_SYNC_PROFILE"] = str(missing_profile)
|
|
47
|
+
|
|
48
|
+
completed = subprocess.run(
|
|
49
|
+
[
|
|
50
|
+
sys.executable,
|
|
51
|
+
str(ROOT / "scripts" / "execute-ready-check.py"),
|
|
52
|
+
"--issue",
|
|
53
|
+
"52",
|
|
54
|
+
"--mode",
|
|
55
|
+
"handoff",
|
|
56
|
+
],
|
|
57
|
+
cwd=ROOT,
|
|
58
|
+
env=environment,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expected_stderr = (
|
|
64
|
+
"[FAIL] execute-ready-check: Board-Profil nicht verfügbar — "
|
|
65
|
+
f"board profile not found at {missing_profile} — run /setup-workflow "
|
|
66
|
+
"to seed docs/agents/board-sync.md.\n"
|
|
67
|
+
)
|
|
68
|
+
diagnostics = (
|
|
69
|
+
f"stdout:\n{completed.stdout}\n"
|
|
70
|
+
f"stderr:\n{completed.stderr}"
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(completed.returncode, 1, diagnostics)
|
|
73
|
+
self.assertEqual(completed.stdout, "", diagnostics)
|
|
74
|
+
self.assertEqual(completed.stderr, expected_stderr, diagnostics)
|
|
75
|
+
|
|
43
76
|
def activate_current(self, root):
|
|
44
77
|
fresh = DRIFT_GUARD.scan_census_status(root)["fresh"]
|
|
45
78
|
(root / ".census" / "active.json").write_text(
|
|
@@ -95,6 +95,15 @@ BEHAVIORAL_PARITY = {
|
|
|
95
95
|
"Program-PRD",
|
|
96
96
|
"program sync",
|
|
97
97
|
),
|
|
98
|
+
"session-owned teardown": (
|
|
99
|
+
"session.py begin",
|
|
100
|
+
"session.py create",
|
|
101
|
+
"refuses reused/pre-existing targets",
|
|
102
|
+
"seal the receipt",
|
|
103
|
+
"compare-deleted",
|
|
104
|
+
"foreign targets",
|
|
105
|
+
"recovery-OID evidence",
|
|
106
|
+
),
|
|
98
107
|
"program authorization continuity": (
|
|
99
108
|
"explicit whole-Program mandate",
|
|
100
109
|
"all planned Waves",
|
|
@@ -210,6 +219,41 @@ class OrchestrateWaveContract(unittest.TestCase):
|
|
|
210
219
|
):
|
|
211
220
|
self.assertIn(fragment, done)
|
|
212
221
|
|
|
222
|
+
def test_phase_one_orders_producers_before_surface_measurers(self):
|
|
223
|
+
prose = " ".join(self.skill.split())
|
|
224
|
+
phase_one = prose.split("## Phase 1", 1)[1].split("## Phase 2", 1)[0]
|
|
225
|
+
for fragment in (
|
|
226
|
+
"Producer/measurer coupling",
|
|
227
|
+
"semantic dependency",
|
|
228
|
+
"measurer strictly after the producer",
|
|
229
|
+
"baseline reconciliation",
|
|
230
|
+
"drop producer-resolved entries",
|
|
231
|
+
):
|
|
232
|
+
self.assertIn(fragment, phase_one)
|
|
233
|
+
|
|
234
|
+
done = phase_one.split("**Done when:**", 1)[1]
|
|
235
|
+
for fragment in (
|
|
236
|
+
"producer/measurer pair",
|
|
237
|
+
"explicit edge/order",
|
|
238
|
+
"reconciliation owner",
|
|
239
|
+
):
|
|
240
|
+
self.assertIn(fragment, done)
|
|
241
|
+
|
|
242
|
+
def test_phase_four_centrally_runs_builder_browser_specs_before_ci(self):
|
|
243
|
+
prose = " ".join(self.skill.split())
|
|
244
|
+
phase_four = prose.split("## Phase 4", 1)[1].split("## Phase 5", 1)[0]
|
|
245
|
+
browser_contract = "builders authored or changed browser specs"
|
|
246
|
+
central_gate = "Re-run your project's full CI/verify gate CENTRALLY yourself"
|
|
247
|
+
for fragment in (
|
|
248
|
+
browser_contract,
|
|
249
|
+
"unverified artifact",
|
|
250
|
+
"ONE central green run",
|
|
251
|
+
"if `§Verify Recipe` names a local e2e runner",
|
|
252
|
+
"Never invent a command under generic fallback",
|
|
253
|
+
):
|
|
254
|
+
self.assertIn(fragment, phase_four)
|
|
255
|
+
self.assertLess(phase_four.index(browser_contract), phase_four.index(central_gate))
|
|
256
|
+
|
|
213
257
|
def test_phase_two_keeps_per_slice_routing_decisions(self):
|
|
214
258
|
prose = " ".join(self.skill.split())
|
|
215
259
|
self.assertIn("(a) inline vs delegate", prose)
|
|
@@ -88,7 +88,7 @@ class WrapupChainingContract(unittest.TestCase):
|
|
|
88
88
|
required = (
|
|
89
89
|
"invoke the `retro` skill immediately in this run",
|
|
90
90
|
"land nothing in this run",
|
|
91
|
-
"fresh explicit `/wrapup` invocation",
|
|
91
|
+
"fresh explicit `$wrapup` or `/wrapup` invocation",
|
|
92
92
|
)
|
|
93
93
|
for surface in SURFACES:
|
|
94
94
|
with self.subTest(surface=surface):
|
|
@@ -120,10 +120,27 @@ class WrapupChainingContract(unittest.TestCase):
|
|
|
120
120
|
with self.subTest(surface=surface):
|
|
121
121
|
text = skill(surface, "wrapup")
|
|
122
122
|
self.assertIn("disable-model-invocation: true", text)
|
|
123
|
-
self.assertIn(
|
|
123
|
+
self.assertIn(
|
|
124
|
+
"user's direct `$wrapup` or `/wrapup` input IS the explicit",
|
|
125
|
+
text,
|
|
126
|
+
)
|
|
124
127
|
self.assertIn("program-sync", text)
|
|
125
128
|
self.assertIn("Phasen-Gates", text)
|
|
126
129
|
|
|
130
|
+
def test_wrapup_accepts_only_direct_user_dollar_or_slash_invocations(self):
|
|
131
|
+
required = (
|
|
132
|
+
"direct `$wrapup` or `/wrapup`",
|
|
133
|
+
"Natural-language requests",
|
|
134
|
+
"indirect skill chaining",
|
|
135
|
+
"autonomous invocation",
|
|
136
|
+
"fresh explicit `$wrapup` or `/wrapup` invocation",
|
|
137
|
+
)
|
|
138
|
+
for surface in SURFACES:
|
|
139
|
+
with self.subTest(surface=surface):
|
|
140
|
+
text = contract_text(surface, "wrapup")
|
|
141
|
+
for phrase in required:
|
|
142
|
+
self.assertIn(phrase, text)
|
|
143
|
+
|
|
127
144
|
def test_new_contract_is_reference_free(self):
|
|
128
145
|
for surface in SURFACES:
|
|
129
146
|
with self.subTest(surface=surface):
|