@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.
Files changed (46) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +20 -12
  2. package/.agents/skills/setup-workflow/SKILL.md +24 -3
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
  5. package/.agents/skills/wrapup/SKILL.md +42 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
  8. package/.claude/skills/setup-workflow/SKILL.md +24 -3
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +33 -10
  13. package/README.md +88 -1
  14. package/agent-workflow-kit.package.json +36 -28
  15. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
  16. package/docs/agents/workflow-capabilities.json +11 -0
  17. package/package.json +1 -1
  18. package/scripts/anchor_table.py +14 -8
  19. package/scripts/project-skill-extension.mjs +21 -2
  20. package/scripts/readiness.mjs +32 -4
  21. package/scripts/release-state.mjs +19 -9
  22. package/scripts/release-state.test.mjs +71 -8
  23. package/scripts/test_anchor_table.py +69 -0
  24. package/scripts/test_board_sync_create_idempotency.py +15 -2
  25. package/scripts/test_board_sync_wave_title.py +14 -2
  26. package/scripts/test_census_backstop.py +33 -0
  27. package/scripts/test_orchestrate_wave_contract.py +44 -0
  28. package/scripts/test_retro_wrapup_contract.py +19 -2
  29. package/scripts/test_worktree_wrapup_contract.py +1004 -3
  30. package/scripts/test_wrapup_land.py +428 -0
  31. package/scripts/workflow-advisories/core.py +44 -2
  32. package/scripts/worktree-lifecycle/README.md +126 -4
  33. package/scripts/worktree-lifecycle/capabilities.json +9 -1
  34. package/scripts/worktree-lifecycle/cleanup.py +143 -5
  35. package/scripts/worktree-lifecycle/core.py +1383 -20
  36. package/scripts/worktree-lifecycle/profile.py +40 -3
  37. package/scripts/worktree-lifecycle/session.py +1857 -0
  38. package/scripts/worktree-lifecycle/setup.py +15 -0
  39. package/scripts/wrapup-land.py +650 -27
  40. package/src/cli.mjs +60 -27
  41. package/src/lib/bundle.mjs +1 -0
  42. package/src/lib/manifest.mjs +173 -3
  43. package/src/lib/projectSkillExtension.mjs +78 -1
  44. package/src/lib/updateCandidate.mjs +3 -1
  45. package/src/lib/updateDecisions.mjs +2 -2
  46. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { resolve } from 'node:path';
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
- return inspectProjectSkillExtension(options);
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)) {
@@ -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 evidence = prod?.verdict ?? await evidenceVerdict(root, capability.evidence);
167
- const diagnostics = prod?.diagnostics?.length ? { diagnostics: prod.diagnostics } : {};
168
- if (evidence === 'invalid') return { state: 'invalid', clearDecision: false, ...diagnostics };
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 verdict = requiredBlocked ? 'blocked' : (inactiveBlocks.length || invalid ? 'degraded' : 'ready');
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
- attempts: 6,
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
- let delay = policy.initialDelayMs;
41
- for (let attempt = 1; attempt <= policy.attempts; attempt += 1) {
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 < policy.attempts) {
45
- await policy.sleep(delay);
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 ${policy.attempts} ${phase.service} read attempts`,
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, isMissingRelease,
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
- spec.loader.exec_module(module)
49
- Path(profile_path).unlink()
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
- spec.loader.exec_module(module)
52
- Path(profile_path).unlink()
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("user's `/wrapup` input IS the explicit", text)
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):