@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.
Files changed (79) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +2 -2
  2. package/.agents/skills/board-to-waves/SKILL.md +13 -3
  3. package/.agents/skills/grill-me/SKILL.md +1 -1
  4. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  5. package/.agents/skills/kit-update/SKILL.md +12 -0
  6. package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
  7. package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
  8. package/.agents/skills/retro/SKILL.md +24 -1
  9. package/.agents/skills/scale-check/SKILL.md +2 -2
  10. package/.agents/skills/setup-workflow/SKILL.md +44 -1
  11. package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
  12. package/.agents/skills/tdd/SKILL.md +68 -14
  13. package/.agents/skills/to-issues/SKILL.md +104 -29
  14. package/.agents/skills/to-prd/SKILL.md +13 -3
  15. package/.agents/skills/to-waves/SKILL.md +16 -4
  16. package/.agents/skills/verify-spike/SKILL.md +1 -1
  17. package/.agents/skills/wrapup/SKILL.md +14 -3
  18. package/.claude/hooks/drift-guard.py +5 -2
  19. package/.claude/hooks/kit-origin-edit-hint.py +64 -0
  20. package/.claude/skills/ask-matt/SKILL.md +2 -2
  21. package/.claude/skills/board-to-waves/SKILL.md +13 -3
  22. package/.claude/skills/codex-build/SKILL.md +69 -23
  23. package/.claude/skills/codex-review/SKILL.md +47 -37
  24. package/.claude/skills/grill-me/SKILL.md +1 -1
  25. package/.claude/skills/grill-me-codex/SKILL.md +45 -34
  26. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  27. package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
  28. package/.claude/skills/kit-update/SKILL.md +12 -0
  29. package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
  30. package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
  31. package/.claude/skills/retro/SKILL.md +23 -0
  32. package/.claude/skills/scale-check/SKILL.md +2 -2
  33. package/.claude/skills/setup-workflow/SKILL.md +44 -1
  34. package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
  35. package/.claude/skills/skill-manifest.json +1 -1
  36. package/.claude/skills/tdd/SKILL.md +68 -14
  37. package/.claude/skills/to-issues/SKILL.md +104 -29
  38. package/.claude/skills/to-prd/SKILL.md +13 -3
  39. package/.claude/skills/to-waves/SKILL.md +16 -4
  40. package/.claude/skills/verify-spike/SKILL.md +1 -1
  41. package/.claude/skills/wrapup/SKILL.md +14 -3
  42. package/README.md +70 -7
  43. package/agent-workflow-kit.package.json +99 -43
  44. package/docs/adr/0001-consumer-divergence-policy.md +49 -0
  45. package/docs/agents/wave-anchor-template.md +1 -1
  46. package/docs/research/wave-152-consumer-acceptance.md +98 -0
  47. package/package.json +1 -1
  48. package/scripts/board-sync.py +187 -12
  49. package/scripts/build-kit.test.mjs +42 -1
  50. package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
  51. package/scripts/codex-exec.sh +566 -0
  52. package/scripts/codex-exec.test.mjs +815 -0
  53. package/scripts/codex_proc.py +602 -0
  54. package/scripts/find-by-marker.py +68 -0
  55. package/scripts/marker_lib.py +88 -0
  56. package/scripts/pr-body-check.py +34 -6
  57. package/scripts/pr_body_e2e.py +83 -0
  58. package/scripts/render-anchor.py +111 -0
  59. package/scripts/test_board_sync.py +208 -0
  60. package/scripts/test_census_backstop.py +56 -3
  61. package/scripts/test_marker_lib.py +154 -0
  62. package/scripts/test_orchestrate_wave_contract.py +116 -0
  63. package/scripts/test_pr_body_check.py +245 -0
  64. package/scripts/test_render_anchor.py +267 -0
  65. package/scripts/test_retro_wrapup_contract.py +117 -0
  66. package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
  67. package/scripts/test_tdd_contract.py +78 -0
  68. package/src/cli.mjs +44 -8
  69. package/src/commands/diff.mjs +5 -2
  70. package/src/commands/init.mjs +12 -0
  71. package/src/commands/own.mjs +16 -0
  72. package/src/commands/uninstall.mjs +8 -1
  73. package/src/commands/update.mjs +37 -9
  74. package/src/lib/bundle.mjs +14 -0
  75. package/src/lib/consumerPath.mjs +30 -0
  76. package/src/lib/manifest.mjs +18 -0
  77. package/src/lib/ownedDiff.mjs +88 -0
  78. package/src/lib/updateCandidate.mjs +14 -0
  79. 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.27.1",
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": {
@@ -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 five board mechanics the planning skills used to inline as bare
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=GH_TIMEOUT_SECONDS)
141
+ timeout=timeout)
133
142
  except subprocess.TimeoutExpired as exc:
134
- raise GhError(f"gh {' '.join(args)} timed out after {GH_TIMEOUT_SECONDS}s") from exc
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
- match = _PROGRAM_CREATE_IDENTITY_RE.search(body)
165
- return f"{match.group(1)}: {match.group(2).strip()}" if match else None
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(_args) -> int:
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").set_defaults(func=cmd_next_wave)
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
+ }