opencode-codeops 1.4.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 (102) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/LICENSE +21 -0
  3. package/README.md +171 -0
  4. package/_shared/auto-design.md +129 -0
  5. package/_shared/layout-convention.md +198 -0
  6. package/_shared/quality-profile.md +134 -0
  7. package/_shared/recommendation-hardening.md +166 -0
  8. package/_shared/scope-expansion-control.md +176 -0
  9. package/_shared/spec-first-ordering.md +79 -0
  10. package/_shared/zero-ambiguity-gate.md +311 -0
  11. package/agent-templates/codebase-scout.md +17 -0
  12. package/agent-templates/concurrency-auditor.md +5 -0
  13. package/agent-templates/design-challenger.md +26 -0
  14. package/agent-templates/financial-integrity-auditor.md +5 -0
  15. package/agent-templates/perf-auditor.md +23 -0
  16. package/agent-templates/phase-reviewer.md +54 -0
  17. package/agent-templates/plan-task-executor-opus.md +46 -0
  18. package/agent-templates/plan-task-executor.md +43 -0
  19. package/agent-templates/preflight-auditor.md +45 -0
  20. package/agent-templates/security-auditor.md +42 -0
  21. package/agent-templates/semantics-reviewer.md +5 -0
  22. package/agent-templates/spec-test-author.md +29 -0
  23. package/agents/concurrency-auditor.md +15 -0
  24. package/agents/correctness-reviewer.md +66 -0
  25. package/agents/demanding-executor.md +58 -0
  26. package/agents/design-challenger.md +38 -0
  27. package/agents/executor.md +55 -0
  28. package/agents/explorer.md +29 -0
  29. package/agents/financial-integrity-auditor.md +15 -0
  30. package/agents/performance-auditor.md +35 -0
  31. package/agents/preflight-auditor.md +57 -0
  32. package/agents/security-auditor.md +54 -0
  33. package/agents/semantics-reviewer.md +15 -0
  34. package/agents/spec-test-author.md +41 -0
  35. package/bin/codeops-worktree +244 -0
  36. package/bin/index.mjs +106 -0
  37. package/bin/install-agents.mjs +453 -0
  38. package/bin/install-skills.mjs +466 -0
  39. package/bin/lib/opencode-install.mjs +185 -0
  40. package/install.sh +55 -0
  41. package/package.json +73 -0
  42. package/plugin/index.ts +181 -0
  43. package/references/domains/compiler-and-language.md +28 -0
  44. package/references/domains/data-and-migration.md +22 -0
  45. package/references/domains/distributed-and-concurrent.md +26 -0
  46. package/references/domains/financial-system.md +28 -0
  47. package/references/domains/selection.md +19 -0
  48. package/references/domains/web-application.md +23 -0
  49. package/schemas/codeops-config.schema.json +56 -0
  50. package/scripts/check-version.mjs +163 -0
  51. package/scripts/codeops-migrate.sh +355 -0
  52. package/scripts/codeops-roadmap-compact.sh +232 -0
  53. package/scripts/codeops-roadmap-sync.sh +275 -0
  54. package/scripts/codeops_outcomes.py +155 -0
  55. package/scripts/codeops_plan.py +239 -0
  56. package/scripts/codeops_plan_migrate.py +318 -0
  57. package/scripts/codeops_worktree_snapshot.py +99 -0
  58. package/scripts/install_agents.py +288 -0
  59. package/scripts/release.mjs +533 -0
  60. package/skills/analyze-project/SKILL.md +28 -0
  61. package/skills/clean-comments/SKILL.md +22 -0
  62. package/skills/exec-plan/SKILL.md +267 -0
  63. package/skills/exec-plan/commit-modes.md +113 -0
  64. package/skills/exec-plan/execution-protocol.md +471 -0
  65. package/skills/git-commit/SKILL.md +35 -0
  66. package/skills/github-issues/SKILL.md +38 -0
  67. package/skills/grill-me/SKILL.md +342 -0
  68. package/skills/make-plan/SKILL.md +282 -0
  69. package/skills/make-plan/quality-checklist.md +96 -0
  70. package/skills/make-plan/templates.md +535 -0
  71. package/skills/make-plan/zero-ambiguity-gate.md +19 -0
  72. package/skills/make-requirements/SKILL.md +268 -0
  73. package/skills/make-requirements/discovery-phases.md +255 -0
  74. package/skills/make-requirements/review-and-add.md +73 -0
  75. package/skills/make-requirements/templates.md +296 -0
  76. package/skills/make-requirements/zero-ambiguity-gate.md +18 -0
  77. package/skills/outcome-review/SKILL.md +34 -0
  78. package/skills/preflight/SKILL.md +310 -0
  79. package/skills/preflight/dimensions.md +181 -0
  80. package/skills/preflight/report-format.md +300 -0
  81. package/skills/retro-requirements/SKILL.md +218 -0
  82. package/skills/retro-requirements/confidence-classification.md +45 -0
  83. package/skills/retro-requirements/phases.md +609 -0
  84. package/skills/retro-requirements/triage-gate.md +135 -0
  85. package/skills/roadmap/SKILL.md +381 -0
  86. package/skills/roadmap/stage-hooks.md +80 -0
  87. package/skills/roadmap/template.md +200 -0
  88. package/skills/setup-codeops/SKILL.md +94 -0
  89. package/skills/setup-codeops/migration.md +106 -0
  90. package/skills/setup-codeops/scaffold.md +99 -0
  91. package/skills/setup-routing/SKILL.md +102 -0
  92. package/skills/setup-routing/routing.md +44 -0
  93. package/skills/techdocs/SKILL.md +199 -0
  94. package/skills/techdocs/authoring-and-update.md +178 -0
  95. package/skills/techdocs/templates.md +655 -0
  96. package/skills/techdocs/vitepress-setup.md +143 -0
  97. package/skills/upgrade-plan/SKILL.md +75 -0
  98. package/skills/upgrade-plan/content-quality-gate.md +35 -0
  99. package/skills/upgrade-plan/upgrade-checklists.md +107 -0
  100. package/standards/coding-standards-full.md +124 -0
  101. package/standards/coding-standards.md +64 -0
  102. package/standards/output-style.md +17 -0
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # codeops-roadmap-sync.sh — deterministic roadmap counter/cascade recomputation.
4
+ #
5
+ # CodeOps Artifact Schema: 1
6
+ #
7
+ # The roadmap skill owns stage JUDGMENT; this script owns the ARITHMETIC (the prose-vs-script
8
+ # division, same as codeops-migrate.sh). It recomputes, from disk:
9
+ # - each roadmap's header `> **Progress**: D / T (P%)` — counting only top-level `RD-*` rows at
10
+ # ✅ Done / total `RD-*` rows (`T-*` task rows and `↳ DEF-n` sub-rows are excluded);
11
+ # - nested layout only: each feature's portfolio row `Progress` (`D/T RDs`) and rolled-up
12
+ # `Status`, and the portfolio header `> **Features**: X / Y done`.
13
+ # Roll-up precedence: any ⛔ → ⛔; all RDs Done with an open `## Open follow-ons` row → 🔄; all RDs
14
+ # Done and none open → ✅; any 🔄 → 🔄; else ⬜.
15
+ # It rewrites a computed value only when the existing value is the engine's own computed shape (a
16
+ # trailing ` · …` / ` (…)` annotation is preserved verbatim); a hand-maintained value (e.g. `n/a`,
17
+ # free text) is left untouched and reported as informational HELD, and that row's Status is not
18
+ # re-rolled. It never infers or changes a Stage cell, never touches Notes or prose, and never
19
+ # executes repo data. Execution-plan checklists and plan metadata are the authoritative inputs;
20
+ # roadmaps remain derived views.
21
+ #
22
+ # Usage:
23
+ # codeops-roadmap-sync.sh # rewrite the computed values in place
24
+ # codeops-roadmap-sync.sh --check # report drift, change nothing; exit 1 on drift
25
+ # codeops-roadmap-sync.sh --dry-run # print the would-be updates, change nothing
26
+ # Exit: 0 = in sync / updated / only HELD; 1 = drift found (--check) or write failure; 2 = bad
27
+ # usage; 3 = python3 unavailable. HELD (preserved hand-maintained values) is informational
28
+ # and never sets a non-zero exit.
29
+
30
+ set -uo pipefail
31
+
32
+ MODE="write"
33
+ for arg in "$@"; do
34
+ case "$arg" in
35
+ --check) MODE="check" ;;
36
+ --dry-run) MODE="dry" ;;
37
+ -h|--help) grep -E '^# ' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
38
+ *) printf 'ERROR: unknown argument: %s\n' "$arg" >&2; exit 2 ;;
39
+ esac
40
+ done
41
+
42
+ command -v python3 >/dev/null 2>&1 || {
43
+ printf 'ERROR: python3 is required for roadmap parsing.\n' >&2
44
+ exit 3
45
+ }
46
+
47
+ # Layout detection — the canonical grep from _shared/layout-convention.md.
48
+ layout="flat"
49
+ if [[ -f codeops/.codeops.yml ]] && grep -Eq '^codeopsLayout:[[:space:]]*nested[[:space:]]*$' codeops/.codeops.yml; then
50
+ layout="nested"
51
+ fi
52
+
53
+ today="$(date '+%Y-%m-%d')"
54
+
55
+ MODE="$MODE" LAYOUT="$layout" TODAY="$today" python3 - <<'PY'
56
+ import glob, os, re, sys
57
+
58
+ mode = os.environ["MODE"] # write | check | dry
59
+ layout = os.environ["LAYOUT"] # flat | nested
60
+ today = os.environ["TODAY"]
61
+
62
+ drift = [] # stale COMPUTED values — a real correction (--check exits 1; write applies it)
63
+ held = [] # preserved hand-maintained values — informational, never an error
64
+ changed = [] # files rewritten (write mode)
65
+
66
+ # Computed-value shapes. Group 1 is the token the engine owns and may rewrite; group 2 is a
67
+ # free-text annotation the engine must preserve verbatim. Patterns are linear — an anchored greedy
68
+ # tail with no nested quantifiers — so untrusted cell text cannot cause pathological backtracking.
69
+ FEATURE_PROGRESS = re.compile(r'^(\d+ / \d+ \(\d+%\))(.*)$') # per-feature "> **Progress**:" value
70
+ PORTFOLIO_PROG = re.compile(r'^(\d+/\d+ RDs)(.*)$') # portfolio Progress cell
71
+ FEATURES_HDR = re.compile(r'^(\d+ / \d+ done)(.*)$') # portfolio "> **Features**:" value
72
+
73
+ def parse_rows(text):
74
+ """Top-level tracker rows as (id, stage, status) — skips header/separator/`↳` sub-rows."""
75
+ rows = []
76
+ in_table = False
77
+ for line in text.splitlines():
78
+ if re.match(r'\|\s*ID\s*\|', line):
79
+ in_table = True
80
+ continue
81
+ if in_table:
82
+ if not line.startswith('|'):
83
+ in_table = False
84
+ continue
85
+ cells = [c.strip() for c in line.strip().strip('|').split('|')]
86
+ if not cells or set(cells[0]) <= {'-', ' '}:
87
+ continue
88
+ rid = cells[0]
89
+ if rid.startswith('↳') or rid in ('—', '-', ''):
90
+ continue
91
+ stage = cells[4] if len(cells) > 4 else ''
92
+ status = cells[5] if len(cells) > 5 else ''
93
+ rows.append((rid, stage, status))
94
+ return rows
95
+
96
+ def is_rd_row(rid):
97
+ """A top-level requirement row. `T-*` tasks and any non-RD id never count toward the fraction."""
98
+ return bool(re.match(r'RD-\d', rid))
99
+
100
+ def progress_of(text):
101
+ """(done, total, pct, rows) — the fraction counts only `RD-*` rows; `rows` keeps every parsed
102
+ top-level row (incl. `T-*`) for the roll-up's status scan."""
103
+ rows = parse_rows(text)
104
+ rd_rows = [r for r in rows if is_rd_row(r[0])]
105
+ total = len(rd_rows)
106
+ done = sum(1 for _, stage, status in rd_rows if '✅' in status or stage.startswith('Done'))
107
+ pct = round(done / total * 100) if total else 0
108
+ return done, total, pct, rows
109
+
110
+ def has_open_followon(text):
111
+ """True iff an `## Open follow-ons` section holds a table whose last column header is `Status`
112
+ and at least one data row's Status cell contains no ✅. A section whose table is not
113
+ `Status`-last is ignored (fail-safe — no false hold). A pure parse — it never affects the RD
114
+ fraction, only the roll-up."""
115
+ in_sec = False
116
+ valid = False # a `Status`-last header row was seen
117
+ for line in text.splitlines():
118
+ if re.match(r'^##\s+Open follow-ons\s*$', line):
119
+ in_sec = True
120
+ valid = False
121
+ continue
122
+ if in_sec and line.startswith('## '):
123
+ break # the next section ends it
124
+ if in_sec and line.startswith('|'):
125
+ cells = [c.strip() for c in line.strip().strip('|').split('|')]
126
+ if not cells or set(cells[-1]) <= {'-', ' '}:
127
+ continue # separator row
128
+ if cells[-1] == 'Status':
129
+ valid = True # header row: last column is `Status`
130
+ continue
131
+ if not valid or not cells[-1]:
132
+ continue # no `Status`-last header yet, or empty cell
133
+ if '✅' not in cells[-1]:
134
+ return True
135
+ return False
136
+
137
+ def replace_header(text, key, new_line):
138
+ """Unconditionally replace a `> **Key**: …` line — used for the computed Last Updated stamp.
139
+ The replacement is a literal callable so data-derived text is never read as a regex template."""
140
+ pat = re.compile(r'(?m)^> \*\*' + re.escape(key) + r'\*\*:.*$')
141
+ if pat.search(text):
142
+ return pat.sub(lambda _m: new_line, text, count=1)
143
+ return text
144
+
145
+ def sub_computed_header(text, key, computed_token, regex, path):
146
+ """Rewrite a computed header (`Progress` / `Features`) only when its value is the engine's own
147
+ computed shape and the token is stale, preserving any trailing free-text suffix. A value that
148
+ is not the computed shape is left untouched and reported HELD."""
149
+ pat = re.compile(r'(?m)^> \*\*' + re.escape(key) + r'\*\*: (.*)$')
150
+ m = pat.search(text)
151
+ if not m:
152
+ drift.append(f"{path}: header line '> **{key}**:' not found (malformed — skipped)")
153
+ return text, False
154
+ value = m.group(1)
155
+ cm = regex.match(value)
156
+ if not cm:
157
+ held.append(f"{path}: {key} '{value}' (hand-maintained)")
158
+ return text, False
159
+ if cm.group(1) == computed_token:
160
+ return text, False # in sync (suffix already preserved in place)
161
+ drift.append(f"{path}: {key} token '{cm.group(1)}' — computed '{computed_token}'")
162
+ new_line = f"> **{key}**: {computed_token}{cm.group(2)}"
163
+ return pat.sub(lambda _m: new_line, text, count=1), True
164
+
165
+ def sync_feature_roadmap(path):
166
+ """Sync one flat/per-feature roadmap header; return (done, total, rows, feat_open)."""
167
+ text = open(path, encoding='utf-8').read()
168
+ done, total, pct, rows = progress_of(text)
169
+ feat_open = has_open_followon(text)
170
+ token = f"{done} / {total} ({pct}%)"
171
+ text2, did = sub_computed_header(text, 'Progress', token, FEATURE_PROGRESS, path)
172
+ if did and mode == 'write':
173
+ text2 = replace_header(text2, 'Last Updated', f"> **Last Updated**: {today}")
174
+ try:
175
+ open(path, 'w', encoding='utf-8').write(text2)
176
+ changed.append(path)
177
+ except OSError as e:
178
+ print(f"ERROR: failed to write {path}: {e}", file=sys.stderr)
179
+ sys.exit(1)
180
+ return done, total, rows, feat_open
181
+
182
+ def roll_up(done, total, rows, feat_open):
183
+ """Feature Status from its rows. `⛔` on any blocked row wins; otherwise all RDs Done holds at
184
+ `🔄` when a follow-on is open, else `✅`; else `🔄` if any row is executing; else `⬜`."""
185
+ statuses = [s for _, _, s in rows]
186
+ if any('⛔' in s for s in statuses):
187
+ return '⛔'
188
+ if total and done == total and feat_open:
189
+ return '🔄'
190
+ if total and done == total:
191
+ return '✅'
192
+ if any('🔄' in s for s in statuses):
193
+ return '🔄'
194
+ return '⬜'
195
+
196
+ if layout == 'flat':
197
+ path = 'plans/00-roadmap.md'
198
+ if not os.path.isfile(path):
199
+ print('codeops-roadmap-sync: no roadmap found (flat layout) — nothing to sync.')
200
+ sys.exit(0)
201
+ sync_feature_roadmap(path)
202
+ else:
203
+ features = {}
204
+ for path in sorted(glob.glob('codeops/features/*/00-roadmap.md')):
205
+ feat = path.split('/')[2]
206
+ features[feat] = sync_feature_roadmap(path)
207
+ # Portfolio cascade.
208
+ ppath = 'codeops/00-roadmap.md'
209
+ if os.path.isfile(ppath):
210
+ text = open(ppath, encoding='utf-8').read()
211
+ lines = text.splitlines()
212
+ out, in_features, feature_rows_total, feature_rows_done = [], False, 0, 0
213
+ for line in lines:
214
+ if line.startswith('## Features'):
215
+ in_features = True
216
+ elif line.startswith('## ') and in_features:
217
+ in_features = False
218
+ if in_features and line.startswith('|'):
219
+ cells = [c.strip() for c in line.strip().strip('|').split('|')]
220
+ feat = cells[0] if cells else ''
221
+ if feat in features and len(cells) >= 6:
222
+ done, total, rows, feat_open = features[feat]
223
+ cm = PORTFOLIO_PROG.match(cells[3])
224
+ if not cm:
225
+ # Hand-maintained cell (n/a, free text): preserve it, do NOT re-roll this
226
+ # row's Status, and exclude it from the computed Features tally.
227
+ held.append(f"{ppath}: row '{feat}' Progress '{cells[3]}' (hand-maintained)")
228
+ else:
229
+ roll = roll_up(done, total, rows, feat_open)
230
+ new_token = f"{done}/{total} RDs"
231
+ if cm.group(1) != new_token or cells[4] != roll:
232
+ drift.append(f"{ppath}: row '{feat}' is '{cells[3]}'/'{cells[4]}' "
233
+ f"— computed '{new_token}'/'{roll}'")
234
+ cells[3] = new_token + cm.group(2)
235
+ cells[4] = roll
236
+ cells[5] = today if mode == 'write' else cells[5]
237
+ line = '| ' + ' | '.join(cells) + ' |'
238
+ feature_rows_total += 1
239
+ feature_rows_done += 1 if roll == '✅' else 0
240
+ out.append(line)
241
+ text2 = '\n'.join(out) + ('\n' if text.endswith('\n') else '')
242
+ feat_token = f"{feature_rows_done} / {feature_rows_total} done"
243
+ text2, _ = sub_computed_header(text2, 'Features', feat_token, FEATURES_HDR, ppath)
244
+ if mode == 'write' and text2 != text:
245
+ text2 = replace_header(text2, 'Last Updated', f"> **Last Updated**: {today}")
246
+ try:
247
+ open(ppath, 'w', encoding='utf-8').write(text2)
248
+ changed.append(ppath)
249
+ except OSError as e:
250
+ print(f"ERROR: failed to write {ppath}: {e}", file=sys.stderr)
251
+ sys.exit(1)
252
+ else:
253
+ drift.append(f"{ppath}: portfolio roadmap missing in nested layout")
254
+
255
+ if held:
256
+ print('codeops-roadmap-sync: preserved hand-maintained values:')
257
+ for h in held:
258
+ print(f' HELD {h}')
259
+
260
+ if drift:
261
+ print('codeops-roadmap-sync: drift detected:')
262
+ for d in drift:
263
+ print(f' DRIFT {d}')
264
+ if mode == 'check':
265
+ sys.exit(1)
266
+ if mode == 'dry':
267
+ print('(dry-run: nothing written)')
268
+ sys.exit(0)
269
+ print(f'codeops-roadmap-sync: updated {len(changed)} file(s): ' + ', '.join(changed))
270
+ sys.exit(0)
271
+ else:
272
+ if not held:
273
+ print('codeops-roadmap-sync: all counters in sync — nothing to do.')
274
+ sys.exit(0)
275
+ PY
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Record and summarize opt-in, content-free CodeOps outcome events."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import sys
11
+ from collections import Counter
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ EVENTS = {
18
+ "task-verified", "verification-run", "review-completed", "finding-escaped",
19
+ "rework-cycle", "scope-drift", "assumption-invalidated", "runtime-ambiguity",
20
+ "recovery-attempt",
21
+ }
22
+ STAGES = {"requirements", "specification", "planning", "execution", "verification", "review", "recovery"}
23
+ RESULTS = {"pass", "fail", "resolved", "blocked", "accurate", "inaccurate"}
24
+
25
+
26
+ def config_for(root: Path) -> dict[str, Any]:
27
+ path = root / "codeops" / "codeops.json"
28
+ if not path.is_file():
29
+ return {}
30
+ try:
31
+ value = json.loads(path.read_text(encoding="utf-8"))
32
+ except (OSError, json.JSONDecodeError):
33
+ return {}
34
+ return value if isinstance(value, dict) else {}
35
+
36
+
37
+ def enabled(root: Path) -> bool:
38
+ return config_for(root).get("metrics", {}).get("enabled") is True
39
+
40
+
41
+ def default_store() -> Path:
42
+ override = os.environ.get("CODEOPS_DATA_DIR")
43
+ # OpenCode keeps its user data under ~/.local/share/opencode; the override
44
+ # lets projects and tests redirect the store without touching the default.
45
+ base = (
46
+ Path(override).expanduser()
47
+ if override
48
+ else Path.home() / ".local" / "share" / "opencode" / "codeops"
49
+ )
50
+ return base / "outcomes.jsonl"
51
+
52
+
53
+ def project_id(root: Path) -> str:
54
+ return hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:12]
55
+
56
+
57
+ def emit(args: argparse.Namespace) -> int:
58
+ root = Path(args.root).resolve()
59
+ if not enabled(root):
60
+ print("CodeOps outcomes disabled; no event recorded.", file=sys.stderr)
61
+ return 0
62
+ if args.event not in EVENTS or args.stage not in STAGES or args.result not in RESULTS:
63
+ print("Invalid event, stage, or result enum.", file=sys.stderr)
64
+ return 2
65
+ event = {
66
+ "schema": 1,
67
+ "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
68
+ "project": project_id(root),
69
+ "event": args.event,
70
+ "stage": args.stage,
71
+ "result": args.result,
72
+ "count": args.count,
73
+ }
74
+ if args.duration_ms is not None:
75
+ event["duration_ms"] = args.duration_ms
76
+ store = Path(args.store).expanduser()
77
+ store.parent.mkdir(parents=True, exist_ok=True)
78
+ with store.open("a", encoding="utf-8") as handle:
79
+ handle.write(json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n")
80
+ print("Outcome event recorded.")
81
+ return 0
82
+
83
+
84
+ def read_events(store: Path, project: str | None) -> list[dict[str, Any]]:
85
+ if not store.is_file():
86
+ return []
87
+ events: list[dict[str, Any]] = []
88
+ for line_number, line in enumerate(store.read_text(encoding="utf-8").splitlines(), 1):
89
+ try:
90
+ value = json.loads(line)
91
+ except json.JSONDecodeError:
92
+ print(f"Ignoring malformed event at line {line_number}.", file=sys.stderr)
93
+ continue
94
+ if isinstance(value, dict) and value.get("schema") == 1 and (project is None or value.get("project") == project):
95
+ events.append(value)
96
+ return events
97
+
98
+
99
+ def report(args: argparse.Namespace) -> int:
100
+ root = Path(args.root).resolve()
101
+ project = None if args.all_projects else project_id(root)
102
+ events = read_events(Path(args.store).expanduser(), project)
103
+ by_event = Counter(event.get("event") for event in events)
104
+ by_result = Counter(event.get("result") for event in events)
105
+ totals = Counter()
106
+ for event in events:
107
+ totals[event.get("event")] += int(event.get("count", 1))
108
+ payload = {
109
+ "events": len(events),
110
+ "by_event": dict(sorted(by_event.items())),
111
+ "by_result": dict(sorted(by_result.items())),
112
+ "totals": dict(sorted(totals.items())),
113
+ }
114
+ if args.as_json:
115
+ print(json.dumps(payload, indent=2, sort_keys=True))
116
+ else:
117
+ print(f"CodeOps outcome events: {len(events)}")
118
+ for event, count in sorted(totals.items()):
119
+ print(f"{event}: {count}")
120
+ if not events:
121
+ print("No local outcome evidence is available for this scope.")
122
+ return 0
123
+
124
+
125
+ def parse_args() -> argparse.Namespace:
126
+ parser = argparse.ArgumentParser(description=__doc__)
127
+ parser.set_defaults(store=str(default_store()))
128
+ sub = parser.add_subparsers(dest="command", required=True)
129
+ emit_parser = sub.add_parser("emit")
130
+ emit_parser.add_argument("--root", default=".")
131
+ emit_parser.add_argument("--store", default=str(default_store()))
132
+ emit_parser.add_argument("--event", required=True, choices=sorted(EVENTS))
133
+ emit_parser.add_argument("--stage", required=True, choices=sorted(STAGES))
134
+ emit_parser.add_argument("--result", required=True, choices=sorted(RESULTS))
135
+ emit_parser.add_argument("--count", type=int, default=1)
136
+ emit_parser.add_argument("--duration-ms", type=int)
137
+ report_parser = sub.add_parser("report")
138
+ report_parser.add_argument("--root", default=".")
139
+ report_parser.add_argument("--store", default=str(default_store()))
140
+ report_parser.add_argument("--all-projects", action="store_true")
141
+ report_parser.add_argument("--json", action="store_true", dest="as_json")
142
+ return parser.parse_args()
143
+
144
+
145
+ def main() -> int:
146
+ args = parse_args()
147
+ duration = getattr(args, "duration_ms", None)
148
+ if getattr(args, "count", 1) < 1 or (duration is not None and duration < 0):
149
+ print("count must be positive and duration must be non-negative.", file=sys.stderr)
150
+ return 2
151
+ return emit(args) if args.command == "emit" else report(args)
152
+
153
+
154
+ if __name__ == "__main__":
155
+ raise SystemExit(main())
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python3
2
+ """Derive CodeOps plan progress directly from Markdown artifacts.
3
+
4
+ This module is intentionally a read-only parser. Markdown remains authoritative;
5
+ the helper has no state store, transition API, revision counter, lock, or journal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ import sys
14
+ from dataclasses import asdict, dataclass
15
+ from pathlib import Path
16
+
17
+
18
+ IMPLEMENTS_RE = re.compile(r"^>[ \t]*\*\*Implements\*\*:[ \t]*(.+?)[ \t]*$", re.MULTILINE)
19
+ TARGET_RE = re.compile(
20
+ r"(?<![A-Za-z0-9_-])(?:[A-Za-z0-9_][A-Za-z0-9_-]*/)?"
21
+ r"(?:RD-(?:[A-Za-z0-9]+-)*\d+|T-\d+|REQ-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)"
22
+ r"(?![A-Za-z0-9_-])"
23
+ )
24
+ TASK_RE = re.compile(r"^-\s*\[([ xX~!])\]\s+(.+?)\s*$", re.MULTILINE)
25
+ BLOCKED_REASON_RE = re.compile(r"(?:blocked|reason)\s*:\s*\S", re.IGNORECASE)
26
+ _PROGRESS_WIDTH = 10
27
+ _PROGRESS_FILLED = "█"
28
+ _PROGRESS_EMPTY = "░"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Task:
33
+ """One authoritative Markdown checklist task and its derived state."""
34
+
35
+ marker: str
36
+ text: str
37
+
38
+ @property
39
+ def state(self) -> str:
40
+ """Return the stable user-facing state represented by the task marker."""
41
+ return {" ": "not-started", "~": "verification-pending", "x": "verified", "!": "blocked"}[
42
+ self.marker.lower()
43
+ ]
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class PlanStatus:
48
+ """Read-only lifecycle, progress, and diagnostics derived for one plan."""
49
+
50
+ plan: str
51
+ implements: tuple[str, ...]
52
+ lifecycle: str
53
+ total: int
54
+ not_started: int
55
+ verification_pending: int
56
+ verified: int
57
+ blocked: int
58
+ next_task: str | None
59
+ problems: tuple[str, ...]
60
+
61
+
62
+ def parse_implements(index_text: str) -> tuple[str, ...]:
63
+ """Return ordered, de-duplicated requirement or tracker targets for a plan."""
64
+ match = IMPLEMENTS_RE.search(index_text)
65
+ if not match:
66
+ return ()
67
+ return tuple(dict.fromkeys(TARGET_RE.findall(match.group(1))))
68
+
69
+
70
+ def parse_tasks(execution_text: str) -> tuple[Task, ...]:
71
+ """Parse only the four authoritative execution checklist markers."""
72
+ return tuple(Task(marker.lower(), text.strip()) for marker, text in TASK_RE.findall(execution_text))
73
+
74
+
75
+ def next_task(tasks: tuple[Task, ...]) -> Task | None:
76
+ """Resume verification first, otherwise start the first untouched task."""
77
+ return next((task for task in tasks if task.marker == "~"), None) or next(
78
+ (task for task in tasks if task.marker == " "), None
79
+ )
80
+
81
+
82
+ def lifecycle(tasks: tuple[Task, ...]) -> str:
83
+ """Derive the plan's Ready/Executing/Done/Blocked lifecycle from its checklist."""
84
+ if any(task.marker == "!" for task in tasks):
85
+ return "Blocked"
86
+ if tasks and all(task.marker == "x" for task in tasks):
87
+ return "Done"
88
+ if any(task.marker in {"~", "x"} for task in tasks):
89
+ return "Executing"
90
+ return "Ready"
91
+
92
+
93
+ def render_progress(verified: int, total: int) -> str:
94
+ """Render verified task progress with both visual and textual context.
95
+
96
+ The bar deliberately rounds filled cells down so it never visually suggests that a completion
97
+ threshold has been reached early. The accompanying rounded percentage and exact fraction make
98
+ the display understandable when block glyphs are unavailable or difficult to distinguish.
99
+
100
+ Args:
101
+ verified: Number of tasks whose verification passed.
102
+ total: Total number of tasks in the checklist.
103
+
104
+ Returns:
105
+ A single user-facing progress line containing ten cells, counts, and percentage.
106
+
107
+ Raises:
108
+ ValueError: If either count is negative or verified exceeds total.
109
+ TypeError: If either count is not an integer.
110
+
111
+ Example:
112
+ >>> render_progress(1, 3)
113
+ 'Progress: [███░░░░░░░] 1/3 tasks (33%)'
114
+ """
115
+ if isinstance(verified, bool) or not isinstance(verified, int):
116
+ raise TypeError("verified must be an integer")
117
+ if isinstance(total, bool) or not isinstance(total, int):
118
+ raise TypeError("total must be an integer")
119
+ if verified < 0 or total < 0:
120
+ raise ValueError("task counts cannot be negative")
121
+ if verified > total:
122
+ raise ValueError("verified tasks cannot exceed total tasks")
123
+
124
+ filled = (verified * _PROGRESS_WIDTH // total) if total else 0
125
+ percentage = round(verified / total * 100) if total else 0
126
+ cells = (_PROGRESS_FILLED * filled) + (_PROGRESS_EMPTY * (_PROGRESS_WIDTH - filled))
127
+ return f"Progress: [{cells}] {verified}/{total} tasks ({percentage}%)"
128
+
129
+
130
+ def inspect_plan(plan_dir: Path, root: Path | None = None) -> PlanStatus:
131
+ """Inspect one plan directory without mutating it."""
132
+ index_path = plan_dir / "00-index.md"
133
+ execution_path = plan_dir / "99-execution-plan.md"
134
+ problems: list[str] = []
135
+ index_text = index_path.read_text(encoding="utf-8") if index_path.is_file() else ""
136
+ execution_text = execution_path.read_text(encoding="utf-8") if execution_path.is_file() else ""
137
+ if not index_path.is_file():
138
+ problems.append("missing required 00-index.md")
139
+ if not execution_path.is_file():
140
+ problems.append("missing required 99-execution-plan.md")
141
+ implements = parse_implements(index_text)
142
+ if not implements:
143
+ problems.append("00-index.md must declare one or more requirement or tracker targets in **Implements**")
144
+ tasks = parse_tasks(execution_text)
145
+ if not tasks:
146
+ problems.append("99-execution-plan.md contains no execution tasks")
147
+ for task in tasks:
148
+ if task.marker == "!" and not BLOCKED_REASON_RE.search(task.text):
149
+ problems.append(f"blocked task lacks a visible 'Blocked: <reason>': {task.text}")
150
+ counts = {marker: sum(task.marker == marker for task in tasks) for marker in (" ", "~", "x", "!")}
151
+ candidate = next_task(tasks)
152
+ display = str(plan_dir.resolve())
153
+ if root is not None:
154
+ try:
155
+ display = plan_dir.resolve().relative_to(root.resolve()).as_posix()
156
+ except ValueError:
157
+ pass
158
+ return PlanStatus(
159
+ plan=display,
160
+ implements=implements,
161
+ lifecycle=lifecycle(tasks),
162
+ total=len(tasks),
163
+ not_started=counts[" "],
164
+ verification_pending=counts["~"],
165
+ verified=counts["x"],
166
+ blocked=counts["!"],
167
+ next_task=candidate.text if candidate else None,
168
+ problems=tuple(problems),
169
+ )
170
+
171
+
172
+ def discover_plans(root: Path) -> tuple[Path, ...]:
173
+ """Discover flat and nested plan directories by their execution plan."""
174
+ paths = set((root / "plans").glob("*/99-execution-plan.md"))
175
+ paths.update((root / "codeops" / "features").glob("*/plans/*/99-execution-plan.md"))
176
+ return tuple(sorted(path.parent for path in paths))
177
+
178
+
179
+ def rd_delivery(statuses: tuple[PlanStatus, ...]) -> dict[str, str]:
180
+ """Derive each RD's delivery state from its implementing plan or plans."""
181
+ grouped: dict[str, list[str]] = {}
182
+ for status in statuses:
183
+ for rd_id in status.implements:
184
+ if not rd_id.rsplit("/", 1)[-1].startswith("RD-"):
185
+ continue
186
+ grouped.setdefault(rd_id, []).append(status.lifecycle)
187
+ result: dict[str, str] = {}
188
+ for rd_id, states in grouped.items():
189
+ if "Blocked" in states:
190
+ result[rd_id] = "Blocked"
191
+ elif states and all(state == "Done" for state in states):
192
+ result[rd_id] = "Done"
193
+ elif any(state == "Executing" for state in states):
194
+ result[rd_id] = "Executing"
195
+ else:
196
+ result[rd_id] = "Ready"
197
+ return result
198
+
199
+
200
+ def build_parser() -> argparse.ArgumentParser:
201
+ """Build the command-line parser for read-only plan inspection."""
202
+ parser = argparse.ArgumentParser(description=__doc__)
203
+ parser.add_argument("--root", type=Path, default=Path.cwd())
204
+ parser.add_argument("--plan", type=Path, help="inspect one plan directory")
205
+ output = parser.add_mutually_exclusive_group()
206
+ output.add_argument("--json", action="store_true")
207
+ output.add_argument(
208
+ "--progress-bar",
209
+ action="store_true",
210
+ help="render verified task progress as an accessible ten-cell bar",
211
+ )
212
+ return parser
213
+
214
+
215
+ def main(argv: list[str] | None = None) -> int:
216
+ """Inspect selected plans and write the requested read-only representation to stdout."""
217
+ args = build_parser().parse_args(argv)
218
+ root = args.root.resolve()
219
+ plan_dirs = (args.plan if args.plan.is_absolute() else root / args.plan,) if args.plan else discover_plans(root)
220
+ statuses = tuple(inspect_plan(path, root) for path in plan_dirs)
221
+ payload = {"plans": [asdict(status) for status in statuses], "requirements": rd_delivery(statuses)}
222
+ if args.json:
223
+ print(json.dumps(payload, indent=2))
224
+ elif args.progress_bar:
225
+ for status in statuses:
226
+ print(render_progress(status.verified, status.total))
227
+ else:
228
+ for status in statuses:
229
+ progress = f"{status.verified}/{status.total} verified"
230
+ print(f"{status.plan}: {status.lifecycle} ({progress})")
231
+ if status.next_task:
232
+ print(f" next: {status.next_task}")
233
+ for problem in status.problems:
234
+ print(f" ERROR: {problem}")
235
+ return 1 if any(status.problems for status in statuses) else 0
236
+
237
+
238
+ if __name__ == "__main__":
239
+ sys.exit(main())