@ikon85/agent-workflow-kit 0.27.1 → 0.29.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/ask-matt/SKILL.md +2 -2
- package/.agents/skills/board-to-waves/SKILL.md +13 -3
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +12 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.agents/skills/retro/SKILL.md +24 -1
- package/.agents/skills/scale-check/SKILL.md +2 -2
- package/.agents/skills/setup-workflow/SKILL.md +44 -1
- package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
- package/.agents/skills/tdd/SKILL.md +68 -14
- package/.agents/skills/to-issues/SKILL.md +104 -29
- package/.agents/skills/to-prd/SKILL.md +13 -3
- package/.agents/skills/to-waves/SKILL.md +16 -4
- package/.agents/skills/verify-spike/SKILL.md +1 -1
- package/.agents/skills/wrapup/SKILL.md +14 -3
- package/.claude/hooks/drift-guard.py +5 -2
- package/.claude/hooks/kit-origin-edit-hint.py +64 -0
- package/.claude/skills/ask-matt/SKILL.md +2 -2
- package/.claude/skills/board-to-waves/SKILL.md +13 -3
- package/.claude/skills/codex-build/SKILL.md +69 -23
- package/.claude/skills/codex-review/SKILL.md +47 -37
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-me-codex/SKILL.md +45 -34
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
- package/.claude/skills/kit-update/SKILL.md +12 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.claude/skills/retro/SKILL.md +23 -0
- package/.claude/skills/scale-check/SKILL.md +2 -2
- package/.claude/skills/setup-workflow/SKILL.md +44 -1
- package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/tdd/SKILL.md +68 -14
- package/.claude/skills/to-issues/SKILL.md +104 -29
- package/.claude/skills/to-prd/SKILL.md +13 -3
- package/.claude/skills/to-waves/SKILL.md +16 -4
- package/.claude/skills/verify-spike/SKILL.md +1 -1
- package/.claude/skills/wrapup/SKILL.md +14 -3
- package/README.md +70 -7
- package/agent-workflow-kit.package.json +99 -43
- package/docs/adr/0001-consumer-divergence-policy.md +49 -0
- package/docs/agents/wave-anchor-template.md +1 -1
- package/docs/research/wave-152-consumer-acceptance.md +98 -0
- package/package.json +1 -1
- package/scripts/board-sync.py +187 -12
- package/scripts/build-kit.test.mjs +42 -1
- package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
- package/scripts/codex-exec.sh +566 -0
- package/scripts/codex-exec.test.mjs +815 -0
- package/scripts/codex_proc.py +602 -0
- package/scripts/find-by-marker.py +68 -0
- package/scripts/marker_lib.py +88 -0
- package/scripts/pr-body-check.py +34 -6
- package/scripts/pr_body_e2e.py +83 -0
- package/scripts/render-anchor.py +111 -0
- package/scripts/test_board_sync.py +208 -0
- package/scripts/test_census_backstop.py +56 -3
- package/scripts/test_marker_lib.py +154 -0
- package/scripts/test_orchestrate_wave_contract.py +116 -0
- package/scripts/test_pr_body_check.py +245 -0
- package/scripts/test_render_anchor.py +267 -0
- package/scripts/test_retro_wrapup_contract.py +117 -0
- package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
- package/scripts/test_tdd_contract.py +78 -0
- package/src/cli.mjs +44 -8
- package/src/commands/diff.mjs +5 -2
- package/src/commands/init.mjs +12 -0
- package/src/commands/own.mjs +16 -0
- package/src/commands/uninstall.mjs +8 -1
- package/src/commands/update.mjs +37 -9
- package/src/lib/bundle.mjs +14 -0
- package/src/lib/consumerPath.mjs +30 -0
- package/src/lib/manifest.mjs +18 -0
- package/src/lib/ownedDiff.mjs +88 -0
- package/src/lib/updateCandidate.mjs +14 -0
- package/src/lib/updateReconcile.mjs +45 -6
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Wave 152 consumer acceptance and release checklist
|
|
2
|
+
|
|
3
|
+
Verified on 2026-07-19 for candidate `52ea49856a03f6e4bb13f24913990769126f81a8`.
|
|
4
|
+
|
|
5
|
+
## Candidate
|
|
6
|
+
|
|
7
|
+
- Source repository: `iKon85/agent-workflow-kit`
|
|
8
|
+
- Candidate tarball: `/tmp/wave152-final-candidate.8CRnlP/ikon85-agent-workflow-kit-0.28.0.tgz`
|
|
9
|
+
- Extracted kit: `/tmp/wave152-final-package.QQTOvE/package/dist-kit`
|
|
10
|
+
- Disposable consumer checkout: `/tmp/wave152-testreporter.ZZg8zY/checkout`
|
|
11
|
+
- Consumer base: `iKon85/Testreporter` `origin/main` at
|
|
12
|
+
`76bcaa91`
|
|
13
|
+
|
|
14
|
+
The temporary paths record the exact acceptance input. They are not release
|
|
15
|
+
artifacts; a repeat run creates fresh temporary directories and a fresh
|
|
16
|
+
test-only marker identity.
|
|
17
|
+
|
|
18
|
+
## Repeatable procedure
|
|
19
|
+
|
|
20
|
+
1. Build the kit from the candidate commit, create an npm tarball, extract it,
|
|
21
|
+
and run its `kit:build`. Install that built kit into a disposable checkout
|
|
22
|
+
of the consumer's current `origin/main`.
|
|
23
|
+
2. Run `scripts/codex-exec.sh new` with the `review` profile and `read-only`
|
|
24
|
+
mode. Capture the opaque run ID, resume it for round 2, verify both rounds
|
|
25
|
+
return `OK`, then finalize and verify that the run state is deleted.
|
|
26
|
+
3. Run `scripts/codex-exec.sh new` with the `build` profile and
|
|
27
|
+
`workspace-write` mode. Ask it to create only an allowlisted ignored scratch
|
|
28
|
+
file containing a fixed sentinel, verify the sentinel, finalize the run,
|
|
29
|
+
and remove the scratch file.
|
|
30
|
+
4. Create a disposable consumer issue through `scripts/board-sync.py` with a
|
|
31
|
+
unique `wave-stub-source` marker. Resolve it through
|
|
32
|
+
`scripts/find-by-marker.py`, verify one exact match, move its board status to
|
|
33
|
+
Done, close it, and repeat the lookup. The terminal lookup must report the
|
|
34
|
+
closed/user-decision branch and an all-open scan must find zero disposable
|
|
35
|
+
issues.
|
|
36
|
+
|
|
37
|
+
## Acceptance evidence
|
|
38
|
+
|
|
39
|
+
| Smoke | Evidence | Result |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `codex-review` read-only | Real new round returned `OK` with a thread; resume round 2 returned `OK`; finalize deleted run state. | PASS |
|
|
42
|
+
| `codex-build` workspace-write | Created only ignored `_ai_temp/wave152-build-smoke.txt` with `WAVE152_BUILD_SMOKE_OK`; finalize succeeded; scratch file removed. | PASS |
|
|
43
|
+
| Marker roundtrip | Identity `wave-stub-source=awkit-wave152-20260719-acceptance-52ea498` created Testreporter issue #2168 through board-sync; exact lookup was unique; board status became Done; issue was closed; final lookup selected closed/user-decision; `openDisposable=0`. | PASS |
|
|
44
|
+
|
|
45
|
+
No open disposable issue or scratch file remained after acceptance. The closed
|
|
46
|
+
issue remains deliberate cleanup evidence and is excluded from open productive
|
|
47
|
+
work by the all-state lookup's closed/user-decision result.
|
|
48
|
+
|
|
49
|
+
## Counted surface census: 31 of 31
|
|
50
|
+
|
|
51
|
+
The denominator is re-derived from the current skill manifest and the delivery
|
|
52
|
+
surfaces, not from the changed-file list.
|
|
53
|
+
|
|
54
|
+
| Surface class | Derivation | Covered |
|
|
55
|
+
|---|---|---:|
|
|
56
|
+
| Skill surfaces | Nine records from `.claude/skills/skill-manifest.json`: four Claude-only Codex skills (4), three dual-surface marker callers (6), dual-surface `to-issues` (2), and dual-surface `wrapup` (2). The four Codex skills intentionally have no invented `.agents` mirrors. | 14/14 |
|
|
57
|
+
| Bundle registrations | The five Wave helpers in `HELPER_FILES`, with their declared kind and mode. | 5/5 |
|
|
58
|
+
| Checked manifest entries | The same five helpers in the freshly built and checked `agent-workflow-kit.package.json`. | 5/5 |
|
|
59
|
+
| Test surfaces | Harness scenarios, thin-skill lifecycle, marker library/CLI, anchor renderer/promotion, wrapup contradiction absence, and bundle/manifest/tarball modes. | 6/6 |
|
|
60
|
+
| Shared board callsite | `scripts/board-sync.py` imports and uses the shared marker parser instead of retaining a second grammar. | 1/1 |
|
|
61
|
+
| **Total** | **No unexplained gap.** | **31/31** |
|
|
62
|
+
|
|
63
|
+
The exact manifest-derived skill set is:
|
|
64
|
+
|
|
65
|
+
- Claude-only: `codex-review`, `codex-build`, `grill-me-codex`,
|
|
66
|
+
`grill-with-docs-codex`.
|
|
67
|
+
- Dual-surface: `to-prd`, `to-waves`, `board-to-waves`, `to-issues`, `wrapup`.
|
|
68
|
+
|
|
69
|
+
The five helper modes are:
|
|
70
|
+
|
|
71
|
+
- `scripts/marker_lib.py` — `0644`
|
|
72
|
+
- `scripts/find-by-marker.py` — `0755`
|
|
73
|
+
- `scripts/codex-exec.sh` — `0755`
|
|
74
|
+
- `scripts/codex_proc.py` — `0644`
|
|
75
|
+
- `scripts/render-anchor.py` — `0755`
|
|
76
|
+
|
|
77
|
+
## Release checklist
|
|
78
|
+
|
|
79
|
+
- [x] K1-K5 integrated into one candidate.
|
|
80
|
+
- [x] K1, K2, and K4 passed independent Standards and Spec reviews.
|
|
81
|
+
- [x] Candidate tarball passed all three real consumer smokes.
|
|
82
|
+
- [x] Disposable consumer issue and scratch file cleaned up; no open disposable
|
|
83
|
+
issue remains.
|
|
84
|
+
- [x] Bundle, built-manifest, and npm-tarball mode assertions cover all five
|
|
85
|
+
helpers.
|
|
86
|
+
- [x] Surface census is 31 of 31 with zero unexplained gaps.
|
|
87
|
+
- [x] Prepared the user-authorized single minor bump from 0.28.0 to 0.29.0 and
|
|
88
|
+
refresh checked release state.
|
|
89
|
+
- [x] `release:prepare -- --version 0.29.0` completed its release guard, full
|
|
90
|
+
test suite, manifest rebuild, and dry-run npm pack.
|
|
91
|
+
- [x] Re-ran the full suite (354 Node tests and 277 Python tests),
|
|
92
|
+
`kit:staleness` (`OK`), and `release:guard` (`OK (minor)`).
|
|
93
|
+
- [x] Created and extracted the final `0.29.0` npm tarball (380 package
|
|
94
|
+
entries); all five helper modes matched both pack metadata and extracted
|
|
95
|
+
filesystem modes.
|
|
96
|
+
- [ ] After merge, let the trusted release workflow publish and verify
|
|
97
|
+
`npm run release:status` reports `released` before declaring the release
|
|
98
|
+
complete.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikon85/agent-workflow-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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/board-sync.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""board-sync.py — single GitHub Projects-v2 board-sync / sub-issue helper.
|
|
3
3
|
|
|
4
|
-
Encapsulates the
|
|
4
|
+
Encapsulates the board mechanics the planning skills used to inline as bare
|
|
5
5
|
`gh` snippets (board-to-waves, to-prd, to-issues):
|
|
6
6
|
|
|
7
7
|
1. GraphQL-Link — native parent↔child sub-issue link (`link`)
|
|
@@ -9,6 +9,10 @@ Encapsulates the five board mechanics the planning skills used to inline as bare
|
|
|
9
9
|
3. preview-header — `GraphQL-Features: sub_issues` on every sub-issue call
|
|
10
10
|
4. Wave-Stempel — stamp the Wave (number) field (`--wave` on `create`/`add`)
|
|
11
11
|
5. board-sync — add an issue to the board + set Status/Wave/Cluster/Path fields
|
|
12
|
+
6. next-wave — rate-efficient title search with a bounded full-scan fallback
|
|
13
|
+
7. promotion guard — reject a Wave number already held by another anchor
|
|
14
|
+
8. item-of — targeted item/field lookup without scanning the whole board
|
|
15
|
+
9. archive-done — dry-run-first, bounded-batch archival of active Done items
|
|
12
16
|
|
|
13
17
|
Board-specific values (field IDs, status names, labels) are NOT inlined here —
|
|
14
18
|
board_config reads them from the `board-sync:profile` block in
|
|
@@ -49,6 +53,7 @@ from node_kind import classify_node, PROGRAM # noqa: E402
|
|
|
49
53
|
# "open blocker" predicate, shared with the execute-ready drift check;
|
|
50
54
|
# native dependencies (API) are the truth.
|
|
51
55
|
from issue_deps import open_blocker_numbers, splice_blocked_by_section # noqa: E402
|
|
56
|
+
from marker_lib import first_marker # noqa: E402
|
|
52
57
|
|
|
53
58
|
# --- Board profile (SSOT: docs/agents/board-sync.md `board-sync:profile`) ----
|
|
54
59
|
# No inline board IDs: board_config reads them from the profile so the published
|
|
@@ -95,6 +100,8 @@ def _status_roles_or_hint(context: str) -> dict:
|
|
|
95
100
|
return STATUS_ROLES
|
|
96
101
|
PROJECT_ITEM_LIST_LIMIT = 2000
|
|
97
102
|
GH_TIMEOUT_SECONDS = 15 # a hanging gh prompt must not block a session indefinitely
|
|
103
|
+
PROJECT_ITEM_LIST_TIMEOUT_SECONDS = 60 # full-board reads are slower, but still bounded
|
|
104
|
+
ARCHIVE_DONE_BATCH_SIZE = 30
|
|
98
105
|
|
|
99
106
|
# Sub-issues GraphQL API is behind a preview feature gate per account.
|
|
100
107
|
SUB_ISSUES_HEADER = "GraphQL-Features: sub_issues"
|
|
@@ -127,11 +134,13 @@ class GhError(RuntimeError):
|
|
|
127
134
|
# --- subprocess seam (the only thing tests monkeypatch) ----------------------
|
|
128
135
|
def _gh(args: list[str]) -> str:
|
|
129
136
|
"""Run `gh <args>`, return stdout. Raise GhError on non-zero exit or timeout."""
|
|
137
|
+
timeout = (PROJECT_ITEM_LIST_TIMEOUT_SECONDS
|
|
138
|
+
if args[:2] == ["project", "item-list"] else GH_TIMEOUT_SECONDS)
|
|
130
139
|
try:
|
|
131
140
|
result = subprocess.run(["gh", *args], capture_output=True, text=True,
|
|
132
|
-
timeout=
|
|
141
|
+
timeout=timeout)
|
|
133
142
|
except subprocess.TimeoutExpired as exc:
|
|
134
|
-
raise GhError(f"gh {' '.join(args)} timed out after {
|
|
143
|
+
raise GhError(f"gh {' '.join(args)} timed out after {timeout}s") from exc
|
|
135
144
|
if result.returncode != 0:
|
|
136
145
|
# `gh api graphql` exits non-zero when the response body carries GraphQL
|
|
137
146
|
# `errors` even alongside a partial `data` (stamp-batch's per-alias
|
|
@@ -154,15 +163,10 @@ def _print_dry(args: list[str]) -> None:
|
|
|
154
163
|
|
|
155
164
|
|
|
156
165
|
# --- pure logic (directly unit-tested) ---------------------------------------
|
|
157
|
-
_PROGRAM_CREATE_IDENTITY_RE = re.compile(
|
|
158
|
-
r"<!--\s*(program-(?:stub|leaf)-source):\s*([^>]+?)\s*-->"
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
166
|
def extract_program_create_identity(body: str) -> Optional[str]:
|
|
163
167
|
"""Stable to-waves identity carried by a stub/leaf body, if present."""
|
|
164
|
-
|
|
165
|
-
return f"{
|
|
168
|
+
marker = first_marker(body, ("program-stub-source", "program-leaf-source"))
|
|
169
|
+
return f"{marker[0]}: {marker[1]}" if marker else None
|
|
166
170
|
|
|
167
171
|
|
|
168
172
|
def _find_open_issue_by_program_identity(body: str) -> Optional[dict]:
|
|
@@ -422,6 +426,10 @@ WAVE_TITLE_PREFIX = wave_title_prefix(_CFG)
|
|
|
422
426
|
_WAVE_PREFIX_RE = re.compile(
|
|
423
427
|
rf"^\s*{re.escape(WAVE_TITLE_PREFIX)}\s+\d+\s*[—–-]\s*", re.IGNORECASE
|
|
424
428
|
)
|
|
429
|
+
_WAVE_NUMBER_RE = re.compile(rf"^{re.escape(WAVE_TITLE_PREFIX)}\s+(\d+)\b")
|
|
430
|
+
_ANCHOR_TITLE_RE = re.compile(
|
|
431
|
+
rf"^{re.escape(WAVE_TITLE_PREFIX)}\s+(\d+)\s+—\s+"
|
|
432
|
+
)
|
|
425
433
|
# Leading conventional-commit token (`fix:`, `feat(ui):`, `chore!:`) — anchors
|
|
426
434
|
# don't carry these, so strip it when present. Matches only a lowercase token,
|
|
427
435
|
# so prose titles like "Supabase-Residuen entfernen: …" are left intact.
|
|
@@ -443,6 +451,26 @@ def wave_title(current: str, wave: int) -> str:
|
|
|
443
451
|
return f"{WAVE_TITLE_PREFIX} {wave} — {thema}"
|
|
444
452
|
|
|
445
453
|
|
|
454
|
+
def compute_next_wave_from_search(payload: dict) -> Optional[int]:
|
|
455
|
+
"""Return max wave-title number + 1, or None so callers can full-scan."""
|
|
456
|
+
numbers = []
|
|
457
|
+
for item in payload.get("items", []):
|
|
458
|
+
match = _WAVE_NUMBER_RE.match(item.get("title") or "")
|
|
459
|
+
if match:
|
|
460
|
+
numbers.append(int(match.group(1)))
|
|
461
|
+
return max(numbers) + 1 if numbers else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def wave_collision_guard(payload: dict, wave: int, issue: int) -> Optional[str]:
|
|
465
|
+
"""Reject a wave number carried by another anchor; allow replay and leaves."""
|
|
466
|
+
for item in payload.get("items", []):
|
|
467
|
+
match = _ANCHOR_TITLE_RE.match(item.get("title") or "")
|
|
468
|
+
if match and int(match.group(1)) == wave and item.get("number") != issue:
|
|
469
|
+
return (f"Wave {wave} is already taken by #{item.get('number')} "
|
|
470
|
+
f"({item.get('title')!r}); re-read `next-wave` and retry")
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
446
474
|
def title_edit_args(issue: int, title: str) -> list[str]:
|
|
447
475
|
"""The `gh issue edit` argv that sets an issue's title."""
|
|
448
476
|
return ["issue", "edit", str(issue), "--repo", REPO, "--title", title]
|
|
@@ -511,6 +539,43 @@ def _field_value(issue: int, field_id: str) -> Optional[dict]:
|
|
|
511
539
|
return extract_field_value(data, PROJECT_NODE_ID, field_id)
|
|
512
540
|
|
|
513
541
|
|
|
542
|
+
def extract_item_overview(data: dict, project_node_id: str, field_ids: dict) -> Optional[dict]:
|
|
543
|
+
"""Extract one issue's item id and configured field values for this project."""
|
|
544
|
+
issue = (data.get("data") or {}).get("repository", {}).get("issue") or {}
|
|
545
|
+
for node in (issue.get("projectItems") or {}).get("nodes") or []:
|
|
546
|
+
if (node.get("project") or {}).get("id") != project_node_id:
|
|
547
|
+
continue
|
|
548
|
+
by_field = {(value.get("field") or {}).get("id"): value
|
|
549
|
+
for value in (node.get("fieldValues") or {}).get("nodes") or []}
|
|
550
|
+
overview = {"itemId": node.get("id")}
|
|
551
|
+
for name, field_id in field_ids.items():
|
|
552
|
+
value = by_field.get(field_id)
|
|
553
|
+
if value is None:
|
|
554
|
+
overview[name] = None
|
|
555
|
+
elif "number" in value:
|
|
556
|
+
overview[name] = value["number"]
|
|
557
|
+
elif "name" in value:
|
|
558
|
+
overview[name] = value["name"]
|
|
559
|
+
else:
|
|
560
|
+
overview[name] = value.get("text")
|
|
561
|
+
return overview
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def cmd_item_of(args) -> int:
|
|
566
|
+
data = _gh_json(["api", "graphql", "-f", f"query={FIELD_VALUE_QUERY}",
|
|
567
|
+
"-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
|
|
568
|
+
"-F", f"num={args.issue}"])
|
|
569
|
+
overview = extract_item_overview(data, PROJECT_NODE_ID, {
|
|
570
|
+
"wave": WAVE_FIELD_ID, "status": STATUS_FIELD_ID, "cluster": CLUSTER_FIELD_ID,
|
|
571
|
+
})
|
|
572
|
+
if overview is None:
|
|
573
|
+
print("NOT-ON-BOARD")
|
|
574
|
+
return 1
|
|
575
|
+
print(json.dumps(overview, ensure_ascii=False))
|
|
576
|
+
return 0
|
|
577
|
+
|
|
578
|
+
|
|
514
579
|
def _write_issue_body(issue: int, new_body: str) -> None:
|
|
515
580
|
"""Write an issue body via a temp file + `gh issue edit --body-file` — the
|
|
516
581
|
one shared write mechanic for every body-rewriting command in this file."""
|
|
@@ -534,13 +599,101 @@ def _add_and_stamp(url, wave, status, cluster, spec_path, plan_path, dry_run) ->
|
|
|
534
599
|
|
|
535
600
|
|
|
536
601
|
# --- commands ----------------------------------------------------------------
|
|
537
|
-
def cmd_next_wave(
|
|
602
|
+
def cmd_next_wave(args) -> int:
|
|
603
|
+
if not getattr(args, "scan", False):
|
|
604
|
+
payload = _gh_json(["api", "-X", "GET", "search/issues",
|
|
605
|
+
"-f", f'q=repo:{REPO} is:issue in:title "{WAVE_TITLE_PREFIX}"',
|
|
606
|
+
"-f", "per_page=100", "-f", "sort=created", "-f", "order=desc"])
|
|
607
|
+
next_wave = compute_next_wave_from_search(payload)
|
|
608
|
+
if next_wave is not None:
|
|
609
|
+
print(next_wave)
|
|
610
|
+
return 0
|
|
611
|
+
print(f"hint: title search found no '{WAVE_TITLE_PREFIX} <N>' issue; "
|
|
612
|
+
"falling back to the full board scan", file=sys.stderr)
|
|
538
613
|
items = _gh_json(["project", "item-list", str(PROJECT_NUMBER), "--owner", PROJECT_OWNER,
|
|
539
614
|
"--limit", str(PROJECT_ITEM_LIST_LIMIT), "--format", "json"]).get("items", [])
|
|
540
615
|
print(compute_next_wave(items))
|
|
541
616
|
return 0
|
|
542
617
|
|
|
543
618
|
|
|
619
|
+
def extract_done_items(items: list[dict], done_status: str) -> list[dict]:
|
|
620
|
+
return [item for item in items
|
|
621
|
+
if item.get("id") and item.get("status") == done_status
|
|
622
|
+
and not item.get("archived", False) and not item.get("isArchived", False)]
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def build_archive_mutation(item_ids: list[str]) -> str:
|
|
626
|
+
calls = []
|
|
627
|
+
for index, item_id in enumerate(item_ids):
|
|
628
|
+
calls.append(
|
|
629
|
+
f"a{index}: archiveProjectV2Item(input: "
|
|
630
|
+
f"{{projectId: {json.dumps(PROJECT_NODE_ID)}, itemId: {json.dumps(item_id)}}}) "
|
|
631
|
+
"{ item { id } }"
|
|
632
|
+
)
|
|
633
|
+
return "mutation { " + " ".join(calls) + " }"
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def parse_archive_response(response: dict, item_ids: list[str]) -> tuple[list[str], list[tuple[str, str]]]:
|
|
637
|
+
data = response.get("data") or {}
|
|
638
|
+
errors = {error["path"][0]: error.get("message", "unknown GraphQL error")
|
|
639
|
+
for error in response.get("errors") or [] if error.get("path")}
|
|
640
|
+
global_errors = [error.get("message", "unknown GraphQL error")
|
|
641
|
+
for error in response.get("errors") or [] if not error.get("path")]
|
|
642
|
+
fallback = "; ".join(global_errors) or "no successful response"
|
|
643
|
+
succeeded, failed = [], []
|
|
644
|
+
for index, item_id in enumerate(item_ids):
|
|
645
|
+
alias = f"a{index}"
|
|
646
|
+
if data.get(alias) and data[alias].get("item"):
|
|
647
|
+
succeeded.append(item_id)
|
|
648
|
+
else:
|
|
649
|
+
failed.append((item_id, errors.get(alias, fallback)))
|
|
650
|
+
return succeeded, failed
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def cmd_archive_done(args) -> int:
|
|
654
|
+
done_status = resolve_status_role("done", _status_roles_or_hint("archive-done"))
|
|
655
|
+
payload = _gh_json(["project", "item-list", str(PROJECT_NUMBER),
|
|
656
|
+
"--owner", PROJECT_OWNER, "--limit", str(PROJECT_ITEM_LIST_LIMIT),
|
|
657
|
+
"--format", "json", "--jq",
|
|
658
|
+
"{items: [.items[] | {id, status, archived, isArchived}], "
|
|
659
|
+
"totalCount: .totalCount}"])
|
|
660
|
+
items = payload.get("items", [])
|
|
661
|
+
total_count = payload.get("totalCount", len(items))
|
|
662
|
+
if total_count > len(items):
|
|
663
|
+
raise ValueError(f"project item-list returned {len(items)} of {total_count} items; "
|
|
664
|
+
"refusing a partial archive")
|
|
665
|
+
done_items = extract_done_items(items, done_status)
|
|
666
|
+
print(f"archive-done: {len(done_items)} of {total_count} active items have status {done_status}")
|
|
667
|
+
if not done_items:
|
|
668
|
+
print("archive-done: nothing to archive (idempotent)")
|
|
669
|
+
return 0
|
|
670
|
+
chunks = [done_items[start:start + ARCHIVE_DONE_BATCH_SIZE]
|
|
671
|
+
for start in range(0, len(done_items), ARCHIVE_DONE_BATCH_SIZE)]
|
|
672
|
+
if not args.apply:
|
|
673
|
+
for chunk in chunks:
|
|
674
|
+
_print_dry(["api", "graphql", "-f",
|
|
675
|
+
f"query={build_archive_mutation([item['id'] for item in chunk])}"])
|
|
676
|
+
print("archive-done: dry-run only; pass --apply to archive")
|
|
677
|
+
return 0
|
|
678
|
+
succeeded, failed = [], []
|
|
679
|
+
for chunk in chunks:
|
|
680
|
+
item_ids = [item["id"] for item in chunk]
|
|
681
|
+
query = build_archive_mutation(item_ids)
|
|
682
|
+
try:
|
|
683
|
+
output = _gh(["api", "graphql", "-f", f"query={query}"])
|
|
684
|
+
except GhError as exc:
|
|
685
|
+
output = getattr(exc, "stdout", "") or ""
|
|
686
|
+
if not output:
|
|
687
|
+
raise
|
|
688
|
+
batch_succeeded, batch_failed = parse_archive_response(json.loads(output), item_ids)
|
|
689
|
+
succeeded += batch_succeeded
|
|
690
|
+
failed += batch_failed
|
|
691
|
+
print(f"archive-done: {len(succeeded)} of {len(done_items)} Done items archived")
|
|
692
|
+
for item_id, message in failed:
|
|
693
|
+
print(f" ERROR {item_id}: {message}")
|
|
694
|
+
return 1 if failed else 0
|
|
695
|
+
|
|
696
|
+
|
|
544
697
|
def cmd_parent_of(args) -> int:
|
|
545
698
|
parent = _parent_of(args.issue)
|
|
546
699
|
print(parent if parent is not None else "FREI")
|
|
@@ -793,6 +946,13 @@ def cmd_promote(args) -> int:
|
|
|
793
946
|
if mismatch:
|
|
794
947
|
print(f"error: {mismatch}", file=sys.stderr)
|
|
795
948
|
return 1
|
|
949
|
+
search = _gh_json(["api", "-X", "GET", "search/issues",
|
|
950
|
+
"-f", f'q=repo:{REPO} is:issue in:title "{WAVE_TITLE_PREFIX} {args.wave}"',
|
|
951
|
+
"-f", "per_page=100"])
|
|
952
|
+
collision = wave_collision_guard(search, args.wave, args.issue)
|
|
953
|
+
if collision:
|
|
954
|
+
print(f"error: {collision}", file=sys.stderr)
|
|
955
|
+
return 1
|
|
796
956
|
done: list[str] = []
|
|
797
957
|
try:
|
|
798
958
|
strip = type_labels_to_strip(labels)
|
|
@@ -1089,7 +1249,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1089
1249
|
p = argparse.ArgumentParser(prog="board-sync.py", description=__doc__.splitlines()[0])
|
|
1090
1250
|
sub = p.add_subparsers(dest="command", required=True)
|
|
1091
1251
|
|
|
1092
|
-
sub.add_parser("next-wave", help="print the next monotone wave number"
|
|
1252
|
+
nw = sub.add_parser("next-wave", help="print the next monotone wave number "
|
|
1253
|
+
"(cheap title search; --scan reads the full board)")
|
|
1254
|
+
nw.add_argument("--scan", action="store_true")
|
|
1255
|
+
nw.set_defaults(func=cmd_next_wave)
|
|
1256
|
+
|
|
1257
|
+
archive = sub.add_parser(
|
|
1258
|
+
"archive-done",
|
|
1259
|
+
help="preview archival of active Done items; --apply performs bounded batches",
|
|
1260
|
+
)
|
|
1261
|
+
archive.add_argument("--apply", action="store_true",
|
|
1262
|
+
help="perform archival (the default is a non-mutating dry-run)")
|
|
1263
|
+
archive.set_defaults(func=cmd_archive_done)
|
|
1264
|
+
|
|
1265
|
+
item = sub.add_parser("item-of", help="targeted item id and field lookup for one issue")
|
|
1266
|
+
item.add_argument("--issue", type=int, required=True)
|
|
1267
|
+
item.set_defaults(func=cmd_item_of)
|
|
1093
1268
|
|
|
1094
1269
|
po = sub.add_parser("parent-of", help="print an issue's parent number or FREI")
|
|
1095
1270
|
po.add_argument("issue", type=int)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -8,12 +8,20 @@ import { createHash } from 'node:crypto';
|
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { buildKit } from './build-kit.mjs';
|
|
10
10
|
import { init } from '../src/commands/init.mjs';
|
|
11
|
+
import { HELPER_FILES } from '../src/lib/bundle.mjs';
|
|
11
12
|
import { CONSUMER_MANIFEST_NAME, readManifest } from '../src/lib/manifest.mjs';
|
|
12
13
|
|
|
13
14
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
15
|
const PUBLIC_CENSUS_UNIT = JSON.parse(await readFile(
|
|
15
16
|
join(REPO, 'test/fixtures/census-consumers/public-unit.json'), 'utf8',
|
|
16
17
|
)).paths;
|
|
18
|
+
const WAVE_152_HELPERS = [
|
|
19
|
+
{ path: 'scripts/marker_lib.py', kind: 'script', mode: 0o644 },
|
|
20
|
+
{ path: 'scripts/find-by-marker.py', kind: 'script', mode: 0o755 },
|
|
21
|
+
{ path: 'scripts/codex-exec.sh', kind: 'script', mode: 0o755 },
|
|
22
|
+
{ path: 'scripts/codex_proc.py', kind: 'script', mode: 0o644 },
|
|
23
|
+
{ path: 'scripts/render-anchor.py', kind: 'script', mode: 0o755 },
|
|
24
|
+
];
|
|
17
25
|
async function withBuild(fn) {
|
|
18
26
|
const dist = await mkdtemp(join(tmpdir(), 'awkit-build-'));
|
|
19
27
|
try { return await fn(dist, await buildKit({ repoRoot: REPO, distDir: dist })); }
|
|
@@ -89,6 +97,24 @@ test('current build contains post-tag public files and repository metadata', asy
|
|
|
89
97
|
});
|
|
90
98
|
});
|
|
91
99
|
|
|
100
|
+
test('Wave 152 helpers keep their bundle and built-manifest modes', async () => {
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
WAVE_152_HELPERS.map(({ path }) => HELPER_FILES.find((entry) => entry.path === path)),
|
|
103
|
+
WAVE_152_HELPERS,
|
|
104
|
+
);
|
|
105
|
+
await withBuild(async (dist) => {
|
|
106
|
+
const manifest = JSON.parse(await readFile(join(dist, 'agent-workflow-kit.package.json'), 'utf8'));
|
|
107
|
+
const byPath = new Map(manifest.files.map((entry) => [entry.path, entry]));
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
WAVE_152_HELPERS.map(({ path }) => {
|
|
110
|
+
const { kind, mode, installRole } = byPath.get(path);
|
|
111
|
+
return { path, kind, mode, installRole };
|
|
112
|
+
}),
|
|
113
|
+
WAVE_152_HELPERS.map((entry) => ({ ...entry, installRole: 'consumer' })),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
92
118
|
test('current build contains the complete dual-surface census consumer unit', async () => {
|
|
93
119
|
await withBuild(async (dist, report) => {
|
|
94
120
|
const manifest = JSON.parse(await readFile(join(dist, 'agent-workflow-kit.package.json'), 'utf8'));
|
|
@@ -129,6 +155,11 @@ test('npm pack keeps product files but excludes runtime residue', async () => {
|
|
|
129
155
|
for (const path of PUBLIC_CENSUS_UNIT) {
|
|
130
156
|
assert.ok(files.includes(path), `pack missing ${path}`);
|
|
131
157
|
}
|
|
158
|
+
const packedByPath = new Map(JSON.parse(output)[0].files.map((file) => [file.path, file]));
|
|
159
|
+
assert.deepEqual(
|
|
160
|
+
WAVE_152_HELPERS.map(({ path, mode }) => ({ path, mode: packedByPath.get(path)?.mode })),
|
|
161
|
+
WAVE_152_HELPERS.map(({ path, mode }) => ({ path, mode })),
|
|
162
|
+
);
|
|
132
163
|
assert.ok(files.every((path) => !path.startsWith('.claude/logs/')));
|
|
133
164
|
assert.ok(files.every((path) => !path.includes('__pycache__') && !path.endsWith('.pyc')));
|
|
134
165
|
const pkg = JSON.parse(execFileSync('node', ['-p', 'JSON.stringify(require("./package.json"))'], {
|
|
@@ -147,6 +178,16 @@ test('packed scoped artifact keeps the existing npx default-bin inference', asyn
|
|
|
147
178
|
const packed = JSON.parse(execFileSync('npm', [
|
|
148
179
|
'pack', '--pack-destination', destination, '--json',
|
|
149
180
|
], { cwd: REPO, encoding: 'utf8' }));
|
|
181
|
+
const packedByPath = new Map(packed[0].files.map((file) => [file.path, file]));
|
|
182
|
+
execFileSync('tar', ['-xzf', join(destination, packed[0].filename), '-C', destination]);
|
|
183
|
+
for (const { path, mode } of WAVE_152_HELPERS) {
|
|
184
|
+
assert.equal(packedByPath.get(path)?.mode, mode, `packed mode mismatch: ${path}`);
|
|
185
|
+
assert.equal(
|
|
186
|
+
(await stat(join(destination, 'package', path))).mode & 0o777,
|
|
187
|
+
mode,
|
|
188
|
+
`extracted mode mismatch: ${path}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
150
191
|
const output = execFileSync('npx', [
|
|
151
192
|
'--yes', '--offline', `./${packed[0].filename}`,
|
|
152
193
|
], { cwd: destination, encoding: 'utf8' });
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const scenario = process.env.FAKE_CODEX_SCENARIO ?? 'ok';
|
|
7
|
+
const pause = Number(process.env.FAKE_CODEX_PAUSE_MS ?? 1_000);
|
|
8
|
+
|
|
9
|
+
if (args.includes('--version') || args[0] === '--version') {
|
|
10
|
+
console.log(`codex-cli ${process.env.FAKE_CODEX_VERSION ?? '0.144.6'}`);
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
if (args[0] === 'login' && args[1] === 'status') {
|
|
14
|
+
if (scenario === 'auth-fail') {
|
|
15
|
+
console.error('Not logged in');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
console.log('Logged in using fake credentials');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
if (args.includes('--help')) {
|
|
22
|
+
const resumeHelp = args[0] === 'exec' && args[1] === 'resume';
|
|
23
|
+
if (process.env.FAKE_CODEX_MISSING_CAPABILITY === '1'
|
|
24
|
+
|| (resumeHelp && process.env.FAKE_CODEX_RESUME_MISSING_CAPABILITY === '1')) {
|
|
25
|
+
console.log('Usage: codex exec [PROMPT]');
|
|
26
|
+
} else if (resumeHelp) {
|
|
27
|
+
console.log('Usage: codex exec resume [--config key=value] [--json] THREAD PROMPT');
|
|
28
|
+
} else {
|
|
29
|
+
console.log('Usage: codex exec [--json] [--sandbox MODE] resume THREAD');
|
|
30
|
+
}
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (process.env.FAKE_CODEX_LAUNCH_LOG) {
|
|
35
|
+
appendFileSync(process.env.FAKE_CODEX_LAUNCH_LOG, `${JSON.stringify(args)}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (args[0] === 'exec' && args[1] === 'resume') {
|
|
39
|
+
const configIndex = args.indexOf('-c');
|
|
40
|
+
const expected = `sandbox_mode=${process.env.FAKE_EXPECTED_SANDBOX ?? 'read-only'}`;
|
|
41
|
+
if (args.includes('--sandbox') || configIndex < 0 || args[configIndex + 1] !== expected) {
|
|
42
|
+
console.error('invalid resume sandbox contract');
|
|
43
|
+
process.exit(64);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const emit = (value) => console.log(JSON.stringify(value));
|
|
48
|
+
const thread = process.env.FAKE_CODEX_THREAD ?? 'fake-thread-1';
|
|
49
|
+
const started = () => emit({ type: 'thread.started', thread_id: thread });
|
|
50
|
+
const verdict = () => emit({
|
|
51
|
+
type: 'item.completed',
|
|
52
|
+
item: { id: 'fake-item', type: 'agent_message', text: 'fake verdict' },
|
|
53
|
+
});
|
|
54
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
|
+
|
|
56
|
+
switch (scenario) {
|
|
57
|
+
case 'silent':
|
|
58
|
+
await sleep(pause);
|
|
59
|
+
break;
|
|
60
|
+
case 'startup-byte-silence':
|
|
61
|
+
process.stderr.write('x');
|
|
62
|
+
await sleep(pause);
|
|
63
|
+
break;
|
|
64
|
+
case 'quiet-post-thread':
|
|
65
|
+
started();
|
|
66
|
+
await sleep(pause);
|
|
67
|
+
verdict();
|
|
68
|
+
break;
|
|
69
|
+
case 'malformed':
|
|
70
|
+
console.log('{definitely-not-json');
|
|
71
|
+
break;
|
|
72
|
+
case 'json-null':
|
|
73
|
+
emit(null);
|
|
74
|
+
break;
|
|
75
|
+
case 'json-array':
|
|
76
|
+
emit([]);
|
|
77
|
+
break;
|
|
78
|
+
case 'json-scalar':
|
|
79
|
+
emit('scalar');
|
|
80
|
+
break;
|
|
81
|
+
case 'item-null':
|
|
82
|
+
emit({ type: 'item.completed', item: null });
|
|
83
|
+
break;
|
|
84
|
+
case 'item-array':
|
|
85
|
+
emit({ type: 'item.completed', item: [] });
|
|
86
|
+
break;
|
|
87
|
+
case 'event-type-null':
|
|
88
|
+
emit({ type: null });
|
|
89
|
+
break;
|
|
90
|
+
case 'event-type-array':
|
|
91
|
+
emit({ type: [] });
|
|
92
|
+
break;
|
|
93
|
+
case 'item-type-null':
|
|
94
|
+
emit({ type: 'item.completed', item: { type: null } });
|
|
95
|
+
break;
|
|
96
|
+
case 'item-type-array':
|
|
97
|
+
emit({ type: 'item.completed', item: { type: [] } });
|
|
98
|
+
break;
|
|
99
|
+
case 'thread-non-string':
|
|
100
|
+
emit({ type: 'thread.started', thread_id: 42 });
|
|
101
|
+
break;
|
|
102
|
+
case 'verdict-non-string':
|
|
103
|
+
started();
|
|
104
|
+
emit({ type: 'item.completed', item: { type: 'agent_message', text: 42 } });
|
|
105
|
+
break;
|
|
106
|
+
case 'missing-thread':
|
|
107
|
+
verdict();
|
|
108
|
+
break;
|
|
109
|
+
case 'missing-verdict':
|
|
110
|
+
started();
|
|
111
|
+
emit({ type: 'turn.completed' });
|
|
112
|
+
break;
|
|
113
|
+
case 'exec-fail':
|
|
114
|
+
started();
|
|
115
|
+
console.error('fake execution failed token=super-secret');
|
|
116
|
+
process.exit(23);
|
|
117
|
+
break;
|
|
118
|
+
case 'split-secret':
|
|
119
|
+
started();
|
|
120
|
+
writeFileSync(2, `${'x'.repeat(65_530)}token=super-secret`);
|
|
121
|
+
process.exit(24);
|
|
122
|
+
break;
|
|
123
|
+
case 'signal':
|
|
124
|
+
process.kill(process.pid, 'SIGTERM');
|
|
125
|
+
break;
|
|
126
|
+
case 'group-hang': {
|
|
127
|
+
started();
|
|
128
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)']);
|
|
129
|
+
if (process.env.FAKE_CODEX_CHILD_PID) {
|
|
130
|
+
writeFileSync(process.env.FAKE_CODEX_CHILD_PID, String(child.pid));
|
|
131
|
+
}
|
|
132
|
+
await sleep(pause);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'orphan-group': {
|
|
136
|
+
started();
|
|
137
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'inherit' });
|
|
138
|
+
if (process.env.FAKE_CODEX_CHILD_PID) {
|
|
139
|
+
writeFileSync(process.env.FAKE_CODEX_CHILD_PID, String(child.pid));
|
|
140
|
+
}
|
|
141
|
+
child.unref();
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case 'pipe-burst':
|
|
145
|
+
process.stderr.write('x'.repeat(2 * 1024 * 1024));
|
|
146
|
+
started();
|
|
147
|
+
verdict();
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
started();
|
|
151
|
+
verdict();
|
|
152
|
+
}
|