@ikon85/agent-workflow-kit 0.36.4 → 0.37.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 (43) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +15 -7
  2. package/.agents/skills/setup-workflow/SKILL.md +16 -2
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
  5. package/.agents/skills/wrapup/SKILL.md +20 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
  8. package/.claude/skills/setup-workflow/SKILL.md +16 -2
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +11 -10
  13. package/README.md +59 -1
  14. package/agent-workflow-kit.package.json +26 -26
  15. package/docs/agents/workflow-capabilities.json +1 -0
  16. package/package.json +1 -1
  17. package/scripts/anchor_table.py +14 -8
  18. package/scripts/project-skill-extension.mjs +21 -2
  19. package/scripts/readiness.mjs +32 -4
  20. package/scripts/release-state.mjs +19 -9
  21. package/scripts/release-state.test.mjs +71 -8
  22. package/scripts/test_anchor_table.py +69 -0
  23. package/scripts/test_board_sync_create_idempotency.py +15 -2
  24. package/scripts/test_board_sync_wave_title.py +14 -2
  25. package/scripts/test_census_backstop.py +33 -0
  26. package/scripts/test_orchestrate_wave_contract.py +35 -0
  27. package/scripts/test_retro_wrapup_contract.py +19 -2
  28. package/scripts/test_wrapup_land.py +428 -0
  29. package/scripts/workflow-advisories/core.py +44 -2
  30. package/scripts/worktree-lifecycle/README.md +18 -4
  31. package/scripts/worktree-lifecycle/cleanup.py +173 -6
  32. package/scripts/worktree-lifecycle/core.py +331 -18
  33. package/scripts/worktree-lifecycle/profile.py +2 -0
  34. package/scripts/wrapup-land.py +336 -15
  35. package/src/cli.mjs +60 -27
  36. package/src/lib/manifest.mjs +173 -3
  37. package/src/lib/projectSkillExtension.mjs +78 -1
  38. package/src/lib/updateCandidate.mjs +11 -1
  39. package/src/lib/updateDecisions.mjs +2 -2
  40. package/src/lib/updateReconcile.mjs +6 -0
  41. package/src/lib/verifyUpdateCandidate.mjs +6 -3
  42. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
  43. package/src/lib/verifyUpdateCandidateTransaction.mjs +23 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.36.4",
2
+ "kitVersion": "0.37.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -447,7 +447,7 @@
447
447
  "ownerSkill": "orchestrate-wave",
448
448
  "surface": "codex",
449
449
  "installRole": "consumer",
450
- "sha256": "36570dfccd6dcfa41774e56fd8e31c794a31da72a609753291cf29f87d2636d6",
450
+ "sha256": "b64529b6449a99a013a97be21826a3ced210ec03458dfd980d3a5e0c257e5238",
451
451
  "mode": 420,
452
452
  "origin": "kit"
453
453
  },
@@ -667,7 +667,7 @@
667
667
  "ownerSkill": "setup-workflow",
668
668
  "surface": "codex",
669
669
  "installRole": "consumer",
670
- "sha256": "4ced75934cb409903344f196253e99c1166d7c8705d92c477bfeda92f8a9cadf",
670
+ "sha256": "78f24c6f32bb4b3dd65293c2121b726fbf495f95d884d0527a32b6f1cfae52f5",
671
671
  "mode": 420,
672
672
  "origin": "kit"
673
673
  },
@@ -687,7 +687,7 @@
687
687
  "ownerSkill": "setup-workflow",
688
688
  "surface": "codex",
689
689
  "installRole": "consumer",
690
- "sha256": "697ef6f6cdb749bf0646b2add95d4d3246df39c48ca6d9ade063a775690ce462",
690
+ "sha256": "6021b95b7ef2f070b7968d3d5c87f72749b0418f22441f0c7b6e2a339c859664",
691
691
  "mode": 420,
692
692
  "origin": "kit"
693
693
  },
@@ -757,7 +757,7 @@
757
757
  "ownerSkill": "setup-workflow",
758
758
  "surface": "codex",
759
759
  "installRole": "consumer",
760
- "sha256": "5e0914874f0b9f940205fbcfcfbba87d0c2df8f31db104d2c5328a0d20c80616",
760
+ "sha256": "a124d7a9f96471ac93202c8fa78b76f98f8396a12a786b921ed704320eb63c4e",
761
761
  "mode": 420,
762
762
  "origin": "kit"
763
763
  },
@@ -997,7 +997,7 @@
997
997
  "ownerSkill": "wrapup",
998
998
  "surface": "codex",
999
999
  "installRole": "consumer",
1000
- "sha256": "51fcbabb90f3b04c46079ed9fb491592d0dbad1e1069723e6054376415127fa0",
1000
+ "sha256": "7e50b98441747d370b1feefae6ee5e63b859d2243b64f72024534a71b1d64482",
1001
1001
  "mode": 420,
1002
1002
  "origin": "kit"
1003
1003
  },
@@ -1143,7 +1143,7 @@
1143
1143
  "path": ".claude/hooks/migration-snapshot-reminder.py",
1144
1144
  "kind": "hook",
1145
1145
  "installRole": "consumer",
1146
- "sha256": "693227fae1aa3e782ed5b61a9e8c3fc8c1880a617efd14cb86ca7f38d9a8f204",
1146
+ "sha256": "0cf691143fc4dbac210f45be35e037686adf678f6f521cfd80d9100bcfbb7e02",
1147
1147
  "mode": 493,
1148
1148
  "origin": "kit"
1149
1149
  },
@@ -1769,7 +1769,7 @@
1769
1769
  "ownerSkill": "orchestrate-wave",
1770
1770
  "surface": "claude",
1771
1771
  "installRole": "consumer",
1772
- "sha256": "672d251912886ac4e44f2a60fd3a35ee82c44e86bef4c19ae40d506eb1cf1427",
1772
+ "sha256": "b64529b6449a99a013a97be21826a3ced210ec03458dfd980d3a5e0c257e5238",
1773
1773
  "mode": 420,
1774
1774
  "origin": "kit"
1775
1775
  },
@@ -2009,7 +2009,7 @@
2009
2009
  "ownerSkill": "setup-workflow",
2010
2010
  "surface": "claude",
2011
2011
  "installRole": "consumer",
2012
- "sha256": "4ced75934cb409903344f196253e99c1166d7c8705d92c477bfeda92f8a9cadf",
2012
+ "sha256": "78f24c6f32bb4b3dd65293c2121b726fbf495f95d884d0527a32b6f1cfae52f5",
2013
2013
  "mode": 420,
2014
2014
  "origin": "kit"
2015
2015
  },
@@ -2029,7 +2029,7 @@
2029
2029
  "ownerSkill": "setup-workflow",
2030
2030
  "surface": "claude",
2031
2031
  "installRole": "consumer",
2032
- "sha256": "697ef6f6cdb749bf0646b2add95d4d3246df39c48ca6d9ade063a775690ce462",
2032
+ "sha256": "6021b95b7ef2f070b7968d3d5c87f72749b0418f22441f0c7b6e2a339c859664",
2033
2033
  "mode": 420,
2034
2034
  "origin": "kit"
2035
2035
  },
@@ -2099,7 +2099,7 @@
2099
2099
  "ownerSkill": "setup-workflow",
2100
2100
  "surface": "claude",
2101
2101
  "installRole": "consumer",
2102
- "sha256": "5e0914874f0b9f940205fbcfcfbba87d0c2df8f31db104d2c5328a0d20c80616",
2102
+ "sha256": "a124d7a9f96471ac93202c8fa78b76f98f8396a12a786b921ed704320eb63c4e",
2103
2103
  "mode": 420,
2104
2104
  "origin": "kit"
2105
2105
  },
@@ -2107,7 +2107,7 @@
2107
2107
  "path": ".claude/skills/skill-manifest.json",
2108
2108
  "kind": "doc",
2109
2109
  "installRole": "consumer",
2110
- "sha256": "290cc3e847a10cb10b4ecb17cf0d45be0f60f95e96b7a70417b3e8da0a32e23c",
2110
+ "sha256": "d2f918149db5d75cc389279afc93516d7c12aca790ddb49de23b198d055c8377",
2111
2111
  "mode": 420,
2112
2112
  "origin": "kit"
2113
2113
  },
@@ -2347,7 +2347,7 @@
2347
2347
  "ownerSkill": "wrapup",
2348
2348
  "surface": "claude",
2349
2349
  "installRole": "consumer",
2350
- "sha256": "33b1de1f10d6ca94b89057d8b1f1afe06ae89209991ea3b6d811a8fb61f910b6",
2350
+ "sha256": "7e50b98441747d370b1feefae6ee5e63b859d2243b64f72024534a71b1d64482",
2351
2351
  "mode": 420,
2352
2352
  "origin": "kit"
2353
2353
  },
@@ -2427,7 +2427,7 @@
2427
2427
  "path": "scripts/anchor_table.py",
2428
2428
  "kind": "script",
2429
2429
  "installRole": "consumer",
2430
- "sha256": "204f2cfce8e641721656e745aa5b9bb664be5fc37667dcd8bc6528b8209aaec3",
2430
+ "sha256": "b8bd0e654137a1e7d309f431ab6e7f76e8049c6a3206730323ae7cc90ebc06e8",
2431
2431
  "mode": 420,
2432
2432
  "origin": "kit"
2433
2433
  },
@@ -2667,7 +2667,7 @@
2667
2667
  "path": "scripts/project-skill-extension.mjs",
2668
2668
  "kind": "script",
2669
2669
  "installRole": "consumer",
2670
- "sha256": "12b7c381774a3db6c0d99ec855337a928b70bbe612012bb06f546afb397efc87",
2670
+ "sha256": "9b038bfef135ba2f8487ced43b863631ddbcb3772700a87b6208f0e95b5213e9",
2671
2671
  "mode": 493,
2672
2672
  "origin": "kit"
2673
2673
  },
@@ -2675,7 +2675,7 @@
2675
2675
  "path": "scripts/readiness.mjs",
2676
2676
  "kind": "script",
2677
2677
  "installRole": "consumer",
2678
- "sha256": "26514c49316aa29fdeeb74fca0655cd7aa801f37e3c81cbd2551bb158138ebe8",
2678
+ "sha256": "385854b0a1d8f7915c59cc4f35a3ee6774c4d4ac631a96357abe11cb81be3021",
2679
2679
  "mode": 493,
2680
2680
  "origin": "kit"
2681
2681
  },
@@ -2699,7 +2699,7 @@
2699
2699
  "path": "scripts/release-state.mjs",
2700
2700
  "kind": "script",
2701
2701
  "installRole": "consumer",
2702
- "sha256": "fd9f7f86e26ab3faba819e0c46e5e87c2dd3a84dc9a6111b4eceb4ebe6a3a344",
2702
+ "sha256": "8e3f640aee36bf83ae4b49e449dd98eac6b747b323909ea947d4829345ccb743",
2703
2703
  "mode": 420,
2704
2704
  "origin": "kit"
2705
2705
  },
@@ -2771,7 +2771,7 @@
2771
2771
  "path": "scripts/workflow-advisories/core.py",
2772
2772
  "kind": "script",
2773
2773
  "installRole": "consumer",
2774
- "sha256": "dc47bc20ed1107d230f45ff7158e4fd81d6473be515e03cbd52274f3e87d840d",
2774
+ "sha256": "75b8d8b781344c54edf9f83c6976a999c10ceea6904af765c7179161e82d3bd6",
2775
2775
  "mode": 420,
2776
2776
  "origin": "kit"
2777
2777
  },
@@ -2787,7 +2787,7 @@
2787
2787
  "path": "scripts/worktree-lifecycle/cleanup.py",
2788
2788
  "kind": "script",
2789
2789
  "installRole": "consumer",
2790
- "sha256": "370af8840732db3b6ba39b4825686f3980f69f612038123bc86b2c68921f02ca",
2790
+ "sha256": "3c1c1113298c7c81b26850df95db8571574a49610cea25b57e208e2fa0a40af9",
2791
2791
  "mode": 493,
2792
2792
  "origin": "kit"
2793
2793
  },
@@ -2795,7 +2795,7 @@
2795
2795
  "path": "scripts/worktree-lifecycle/core.py",
2796
2796
  "kind": "script",
2797
2797
  "installRole": "consumer",
2798
- "sha256": "d54c4d663c234b7d2e607b99f6090b46104c21ba4326ef03bcb65de39d1cce19",
2798
+ "sha256": "d4846481f0e7eaeb8e87324a705f94ca323163e3d368b2d809bb572dfea6cd9b",
2799
2799
  "mode": 420,
2800
2800
  "origin": "kit"
2801
2801
  },
@@ -2803,7 +2803,7 @@
2803
2803
  "path": "scripts/worktree-lifecycle/profile.py",
2804
2804
  "kind": "script",
2805
2805
  "installRole": "consumer",
2806
- "sha256": "993b105efd2329b3a206b0c79399aff1c7840017362745966f9b158b1f165f09",
2806
+ "sha256": "ad98f18aa6a24a08fdcbf271c0a7f14543ebb1a085ff3343966ce18f115781a5",
2807
2807
  "mode": 420,
2808
2808
  "origin": "kit"
2809
2809
  },
@@ -2811,7 +2811,7 @@
2811
2811
  "path": "scripts/worktree-lifecycle/README.md",
2812
2812
  "kind": "doc",
2813
2813
  "installRole": "consumer",
2814
- "sha256": "5dba0332253c37162ad1b2f1b7bf504a23af85bf297fdaa8920036f610ff754a",
2814
+ "sha256": "567a59845db059fc6385ea200a8f361e1250f857babaef01d9fa15054be0f753",
2815
2815
  "mode": 420,
2816
2816
  "origin": "kit"
2817
2817
  },
@@ -2827,7 +2827,7 @@
2827
2827
  "path": "scripts/wrapup-land.py",
2828
2828
  "kind": "script",
2829
2829
  "installRole": "consumer",
2830
- "sha256": "ee1f567e7be4f7baae222ed3974d39e5efee93edb2a663cacc8e18b567fa837e",
2830
+ "sha256": "0f365204c5fd1b707a19fda0c4b4c4c9395f371adeaa8aa808f47e8d94a54bf6",
2831
2831
  "mode": 493,
2832
2832
  "origin": "kit"
2833
2833
  },
@@ -2883,7 +2883,7 @@
2883
2883
  "path": "src/lib/manifest.mjs",
2884
2884
  "kind": "script",
2885
2885
  "installRole": "consumer",
2886
- "sha256": "f2920f2b25a700be080fbba122a809bf509080673b806aaab2b0c773c8d8267f",
2886
+ "sha256": "9f8b4cc90d710a616efd82dce2389504efb27781ffa769f9617108dcba96cb0e",
2887
2887
  "mode": 420,
2888
2888
  "origin": "kit"
2889
2889
  },
@@ -2899,7 +2899,7 @@
2899
2899
  "path": "src/lib/projectSkillExtension.mjs",
2900
2900
  "kind": "script",
2901
2901
  "installRole": "consumer",
2902
- "sha256": "2d8708b230bd9d34474c613cfffc89bdbd11cc128837cdaeab12fc06acea2b04",
2902
+ "sha256": "63fa51c0aa6a0bf1b7168467c33810ecca95d9f1252a1455cbce122d3d4cf8d2",
2903
2903
  "mode": 420,
2904
2904
  "origin": "kit"
2905
2905
  },
@@ -3091,7 +3091,7 @@
3091
3091
  "path": "src/lib/updateDecisions.mjs",
3092
3092
  "kind": "script",
3093
3093
  "installRole": "consumer",
3094
- "sha256": "d80969d5d8c6b084289dfc4871924fb1835bbec6cf08b859ecd81bb95e6f767b",
3094
+ "sha256": "1df6ce6a281fb559b9c7663905f67ab3a4cdae80b365882a395f7b37b57e1672",
3095
3095
  "mode": 420,
3096
3096
  "origin": "kit"
3097
3097
  },
@@ -7,6 +7,7 @@
7
7
  "branchRegex": "^(?:feat|fix|chore|docs)/(?P<issue>\\d+)-",
8
8
  "mainBranches": ["main"],
9
9
  "protectedBranches": ["main"],
10
+ "scratchPatterns": ["PLAN.md", "PLAN-REVIEW-LOG.md"],
10
11
  "setupEntry": "python3 scripts/worktree-lifecycle/setup.py",
11
12
  "setupSteps": [],
12
13
  "riskyCommandPatterns": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.36.4",
3
+ "version": "0.37.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": {
@@ -29,7 +29,7 @@ SLICE_TABLE_START = "<!-- slice-table:start -->"
29
29
  SLICE_TABLE_END = "<!-- slice-table:end -->"
30
30
 
31
31
  _SUBISSUE_NUM_RE = re.compile(r"#(\d+)")
32
- _STATUS_BASE_RE = re.compile(r"^(✅\s*#\d+|🔄|⬜)")
32
+ _STATUS_BASE_RE = re.compile(r"^(✅(?:\s*#\d+)?|🔄|⬜)")
33
33
  _STATUS_RANK = {"⬜": 0, "🔄": 1}
34
34
 
35
35
 
@@ -113,17 +113,23 @@ def first_subissue_num(cell: str) -> Optional[int]:
113
113
 
114
114
  # --- volatile-cell refresh (board → cell) ------------------------------------
115
115
  def status_token_from_board(entry: dict, roles: dict) -> str:
116
- """Canonical Status token from board state: ✅ #<PR> (merged) · 🔄 (open PR or
117
- an in-flight board status) · (otherwise).
118
-
119
- Which status NAMES count as in-flight comes from the profile's role map
120
- (`roles["inProgress"]`/`roles["review"]`) — passed in as a plain
121
- dict so this module stays pure (no board_config import). Empty roles →
122
- board-status names can't be interpreted (⬜), PR signals still work."""
116
+ """Canonical Status token from board state: ✅ #<PR> (merged) · (closed
117
+ issue in the configured done status) · 🔄 (open PR or an in-flight board
118
+ status) · ⬜ (otherwise).
119
+
120
+ Status NAMES come from the profile's role map (`roles["done"]`,
121
+ `roles["inProgress"]`, `roles["review"]`) passed in as a plain dict so
122
+ this module stays pure (no board_config import). Empty roles board-status
123
+ names can't be interpreted (⬜), PR signals still work."""
123
124
  prs = entry.get("prs") or []
124
125
  merged = [p for p in prs if p.get("state") == "MERGED"]
125
126
  if merged:
126
127
  return f"✅ #{merged[-1]['number']}"
128
+ done = roles.get("done")
129
+ if (done is not None
130
+ and entry.get("state") == "closed"
131
+ and entry.get("status") == done):
132
+ return "✅"
127
133
  active = {roles.get("inProgress"), roles.get("review")} - {None}
128
134
  if [p for p in prs if p.get("state") == "OPEN"] or entry.get("status") in active:
129
135
  return "🔄"
@@ -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()