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.
- package/CHANGELOG.md +179 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/_shared/auto-design.md +129 -0
- package/_shared/layout-convention.md +198 -0
- package/_shared/quality-profile.md +134 -0
- package/_shared/recommendation-hardening.md +166 -0
- package/_shared/scope-expansion-control.md +176 -0
- package/_shared/spec-first-ordering.md +79 -0
- package/_shared/zero-ambiguity-gate.md +311 -0
- package/agent-templates/codebase-scout.md +17 -0
- package/agent-templates/concurrency-auditor.md +5 -0
- package/agent-templates/design-challenger.md +26 -0
- package/agent-templates/financial-integrity-auditor.md +5 -0
- package/agent-templates/perf-auditor.md +23 -0
- package/agent-templates/phase-reviewer.md +54 -0
- package/agent-templates/plan-task-executor-opus.md +46 -0
- package/agent-templates/plan-task-executor.md +43 -0
- package/agent-templates/preflight-auditor.md +45 -0
- package/agent-templates/security-auditor.md +42 -0
- package/agent-templates/semantics-reviewer.md +5 -0
- package/agent-templates/spec-test-author.md +29 -0
- package/agents/concurrency-auditor.md +15 -0
- package/agents/correctness-reviewer.md +66 -0
- package/agents/demanding-executor.md +58 -0
- package/agents/design-challenger.md +38 -0
- package/agents/executor.md +55 -0
- package/agents/explorer.md +29 -0
- package/agents/financial-integrity-auditor.md +15 -0
- package/agents/performance-auditor.md +35 -0
- package/agents/preflight-auditor.md +57 -0
- package/agents/security-auditor.md +54 -0
- package/agents/semantics-reviewer.md +15 -0
- package/agents/spec-test-author.md +41 -0
- package/bin/codeops-worktree +244 -0
- package/bin/index.mjs +106 -0
- package/bin/install-agents.mjs +453 -0
- package/bin/install-skills.mjs +466 -0
- package/bin/lib/opencode-install.mjs +185 -0
- package/install.sh +55 -0
- package/package.json +73 -0
- package/plugin/index.ts +181 -0
- package/references/domains/compiler-and-language.md +28 -0
- package/references/domains/data-and-migration.md +22 -0
- package/references/domains/distributed-and-concurrent.md +26 -0
- package/references/domains/financial-system.md +28 -0
- package/references/domains/selection.md +19 -0
- package/references/domains/web-application.md +23 -0
- package/schemas/codeops-config.schema.json +56 -0
- package/scripts/check-version.mjs +163 -0
- package/scripts/codeops-migrate.sh +355 -0
- package/scripts/codeops-roadmap-compact.sh +232 -0
- package/scripts/codeops-roadmap-sync.sh +275 -0
- package/scripts/codeops_outcomes.py +155 -0
- package/scripts/codeops_plan.py +239 -0
- package/scripts/codeops_plan_migrate.py +318 -0
- package/scripts/codeops_worktree_snapshot.py +99 -0
- package/scripts/install_agents.py +288 -0
- package/scripts/release.mjs +533 -0
- package/skills/analyze-project/SKILL.md +28 -0
- package/skills/clean-comments/SKILL.md +22 -0
- package/skills/exec-plan/SKILL.md +267 -0
- package/skills/exec-plan/commit-modes.md +113 -0
- package/skills/exec-plan/execution-protocol.md +471 -0
- package/skills/git-commit/SKILL.md +35 -0
- package/skills/github-issues/SKILL.md +38 -0
- package/skills/grill-me/SKILL.md +342 -0
- package/skills/make-plan/SKILL.md +282 -0
- package/skills/make-plan/quality-checklist.md +96 -0
- package/skills/make-plan/templates.md +535 -0
- package/skills/make-plan/zero-ambiguity-gate.md +19 -0
- package/skills/make-requirements/SKILL.md +268 -0
- package/skills/make-requirements/discovery-phases.md +255 -0
- package/skills/make-requirements/review-and-add.md +73 -0
- package/skills/make-requirements/templates.md +296 -0
- package/skills/make-requirements/zero-ambiguity-gate.md +18 -0
- package/skills/outcome-review/SKILL.md +34 -0
- package/skills/preflight/SKILL.md +310 -0
- package/skills/preflight/dimensions.md +181 -0
- package/skills/preflight/report-format.md +300 -0
- package/skills/retro-requirements/SKILL.md +218 -0
- package/skills/retro-requirements/confidence-classification.md +45 -0
- package/skills/retro-requirements/phases.md +609 -0
- package/skills/retro-requirements/triage-gate.md +135 -0
- package/skills/roadmap/SKILL.md +381 -0
- package/skills/roadmap/stage-hooks.md +80 -0
- package/skills/roadmap/template.md +200 -0
- package/skills/setup-codeops/SKILL.md +94 -0
- package/skills/setup-codeops/migration.md +106 -0
- package/skills/setup-codeops/scaffold.md +99 -0
- package/skills/setup-routing/SKILL.md +102 -0
- package/skills/setup-routing/routing.md +44 -0
- package/skills/techdocs/SKILL.md +199 -0
- package/skills/techdocs/authoring-and-update.md +178 -0
- package/skills/techdocs/templates.md +655 -0
- package/skills/techdocs/vitepress-setup.md +143 -0
- package/skills/upgrade-plan/SKILL.md +75 -0
- package/skills/upgrade-plan/content-quality-gate.md +35 -0
- package/skills/upgrade-plan/upgrade-checklists.md +107 -0
- package/standards/coding-standards-full.md +124 -0
- package/standards/coding-standards.md +64 -0
- 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())
|