@ikon85/agent-workflow-kit 0.36.5 → 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.
- package/.agents/skills/orchestrate-wave/SKILL.md +15 -7
- package/.agents/skills/setup-workflow/SKILL.md +16 -2
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.agents/skills/wrapup/SKILL.md +20 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
- package/.claude/skills/setup-workflow/SKILL.md +16 -2
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +11 -10
- package/README.md +52 -1
- package/agent-workflow-kit.package.json +26 -26
- package/docs/agents/workflow-capabilities.json +1 -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 +35 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +18 -4
- package/scripts/worktree-lifecycle/cleanup.py +173 -6
- package/scripts/worktree-lifecycle/core.py +331 -18
- package/scripts/worktree-lifecycle/profile.py +2 -0
- package/scripts/wrapup-land.py +336 -15
- package/src/cli.mjs +60 -27
- 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,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kitVersion": "0.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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": {
|
package/scripts/anchor_table.py
CHANGED
|
@@ -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"^(
|
|
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) ·
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
(`roles["
|
|
121
|
-
|
|
122
|
-
|
|
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 {
|
|
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()
|