okstra 0.50.0 → 0.52.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 (61) hide show
  1. package/README.kr.md +8 -7
  2. package/README.md +8 -7
  3. package/bin/okstra +2 -0
  4. package/docs/kr/architecture.md +15 -16
  5. package/docs/kr/cli.md +5 -5
  6. package/docs/project-structure-overview.md +10 -6
  7. package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +15 -11
  11. package/runtime/agents/workers/claude-worker.md +3 -3
  12. package/runtime/agents/workers/codex-worker.md +2 -2
  13. package/runtime/agents/workers/gemini-worker.md +2 -2
  14. package/runtime/bin/lib/okstra/cli.sh +8 -1
  15. package/runtime/bin/lib/okstra/globals.sh +3 -0
  16. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  17. package/runtime/bin/lib/okstra/usage.sh +6 -0
  18. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  19. package/runtime/bin/okstra.sh +2 -0
  20. package/runtime/prompts/launch.template.md +3 -1
  21. package/runtime/prompts/profiles/_common-contract.md +4 -4
  22. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  23. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  24. package/runtime/prompts/profiles/implementation-planning.md +8 -4
  25. package/runtime/prompts/profiles/implementation.md +1 -1
  26. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  27. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  28. package/runtime/python/okstra_ctl/migrate.py +2 -12
  29. package/runtime/python/okstra_ctl/paths.py +22 -0
  30. package/runtime/python/okstra_ctl/render.py +284 -125
  31. package/runtime/python/okstra_ctl/render_final_report.py +31 -0
  32. package/runtime/python/okstra_ctl/run.py +507 -245
  33. package/runtime/python/okstra_ctl/sequence.py +2 -5
  34. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  35. package/runtime/python/okstra_ctl/wizard.py +129 -133
  36. package/runtime/python/okstra_ctl/worktree.py +13 -5
  37. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  38. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  39. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  40. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  41. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  42. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  43. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  44. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  45. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  46. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  47. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  48. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  49. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  50. package/runtime/skills/okstra-memory/SKILL.md +28 -5
  51. package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
  52. package/runtime/skills/okstra-run/SKILL.md +1 -1
  53. package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
  54. package/runtime/templates/reports/final-report.template.md +1 -0
  55. package/runtime/templates/worker-prompt-preamble.md +3 -3
  56. package/runtime/validators/validate-implementation-plan-stages.py +57 -11
  57. package/src/_python-helper.mjs +3 -3
  58. package/src/context-cost.mjs +27 -0
  59. package/src/install.mjs +1 -0
  60. package/src/memory.mjs +50 -11
  61. package/src/uninstall.mjs +1 -0
@@ -18,6 +18,7 @@ project-id: {{ frontmatter.projectId | yaml_scalar }}
18
18
  taskType: {{ frontmatter.taskType | yaml_scalar }}
19
19
  workerId: {{ frontmatter.workerId | yaml_scalar }}
20
20
  approved: {{ frontmatter.approved | yaml_scalar }}
21
+ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
21
22
  ---
22
23
 
23
24
  # {{ header.taskKey }} - Multi-Agent Cross Verification Final Report
@@ -8,7 +8,7 @@ It replaces the previous practice of inlining ~80 lines of identical boilerplate
8
8
 
9
9
  ## Required reading (analysis workers + report-writer worker)
10
10
 
11
- You are required to read every input file enumerated by the dispatcher (the lead's prompt lists them under `[Required reading]`) from the very first character to the very last character before you produce any analysis output. Skimming, partial reads, jumping to a single section, or relying on prior knowledge of a similar file's structure is not acceptable. Each file may contain decisive context that is not surfaced in its summary or first page.
11
+ You are required to read every primary input file enumerated by the dispatcher (the lead's prompt lists them under `[Required reading]`) from the very first character to the very last character before you produce any analysis output. Skimming, partial reads, jumping to a single section, or relying on prior knowledge of a similar file's structure is not acceptable. Source files listed as fallback/evidence paths are read on demand when you need to verify a citation, resolve ambiguity, or inspect material the packet says it omitted.
12
12
 
13
13
  ### Audience-scoped enumeration (BLOCKING — performance optimization)
14
14
 
@@ -16,7 +16,7 @@ Different recipients need different files. Do NOT include `final-report-template
16
16
 
17
17
  | Recipient | Files included in `[Required reading]` |
18
18
  |---|---|
19
- | Claude / Codex / Gemini analysis workers | task-brief, analysis-profile, analysis-material (if present), reference-expectations, clarification-response (if carry-in) |
19
+ | Claude / Codex / Gemini analysis workers | analysis-packet.md as the primary compact input; task-brief, analysis-profile, analysis-material, reference-expectations, and clarification-response remain source/fallback paths, not automatic first-read files |
20
20
  | Report writer worker (Phase 6) | all of the above **plus** the instruction-set-local `final-report-template.md` (phase-stripped) and `final-report-schema.json` (per-task-type excerpt) — NOT the full `templates/reports/...` / `schemas/...` sources |
21
21
  | Reverify dispatches (Phase 5.5, lightweight mode) | **do NOT inject `[Required reading]` at all** — see [okstra-convergence](../skills/okstra-convergence/SKILL.md) "Reverify prompt: required-reading suppression". |
22
22
 
@@ -25,7 +25,7 @@ Different recipients need different files. Do NOT include `final-report-template
25
25
  - Use a single `Read` tool call per file with no `offset` and no `limit`. If a file is genuinely too large for one read, page through with explicit `offset` / `limit` covering the entire file, and state the page boundaries in your Findings.
26
26
  - For the carry-in clarification response, walk every row of `## 1. Clarification Items` (`C-001`, `C-002`, ...) in full, including rows whose `User input` cell is blank — a blank `User input` with `Status=open` is itself a signal you must surface. The structural similarity between the prior final report and the upcoming output is NOT a license to skim.
27
27
  - Write the Reading Confirmation block to your **audit sidecar** at `runs/<task-type>/worker-results/<worker>-audit-<task-type>-<seq>.md` (sibling to the main worker-results file). One short line per input file confirming end-to-end reading. Do NOT include a `## 0. Reading Confirmation` heading in the main worker-results file — the validator fails worker-results that contain one. If you cannot truthfully confirm a file end-to-end, record a `tool-failure` in the errors sidecar instead of fabricating Findings.
28
- - Do not collapse multiple input files into a single mental summary before reading them all individually. Each file has its own canonical role: brief = the user's request, profile = the lead's rules for this phase, reference-expectations = ground-truth config/deployment values, clarification-response = prior run's open questions and the user's answers, final-report-template = the structure your eventual writeup must conform to. Conflating them loses signal.
28
+ - Treat `analysis-packet.md` as the canonical primary analysis input. It preserves the source files' frontmatter and extracts the task-specific brief, phase focus, reference expectations, and carry-in clarification rows. If the packet appears incomplete or a finding depends on a source citation, open the corresponding source file and cite it directly.
29
29
 
30
30
  ---
31
31
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """S1–S9 checks for the Stage Map structure of an approved
2
+ """S1–S10 checks for the Stage Map structure of an approved
3
3
  implementation-planning final-report.md. Run from prepare_task_bundle
4
4
  of `implementation` task or standalone."""
5
5
 
@@ -40,7 +40,7 @@ class StageMeta:
40
40
 
41
41
  @dataclass
42
42
  class ValidationError:
43
- code: str # S1..S9
43
+ code: str # S1..S10
44
44
  stage: int # 0 = global
45
45
  message: str
46
46
 
@@ -104,30 +104,36 @@ def _slice_stage_section(text: str, stage_number: int) -> str:
104
104
  return text[start: start + nxt.start()] if nxt else text[start:]
105
105
 
106
106
 
107
- def _count_effective_steps(section: str) -> int:
107
+ def _effective_step_rows(section: str) -> List[List[str]]:
108
+ """Effective (non header/divider/comment) rows of the `### Stepwise
109
+ Execution Order` table, each as a list of stripped cells. Columns are
110
+ `step | action | files | command | expected`, so action is index 1."""
108
111
  m = re.search(r"^###\s+Stepwise Execution Order\b", section, re.M)
109
112
  if not m:
110
- return 0
113
+ return []
111
114
  body = section[m.end():]
112
115
  nxt = re.search(r"^###\s+\w", body, re.M)
113
116
  if nxt:
114
117
  body = body[: nxt.start()]
115
- count = 0
118
+ rows: List[List[str]] = []
116
119
  for line in body.splitlines():
117
120
  s = line.strip()
118
121
  if not s or s.startswith("<!--"):
119
122
  continue
120
123
  if not s.startswith("|"):
121
124
  continue
122
- # Reuse the same header/divider detection as _parse_stage_map:
123
- # split on `|`, inspect first non-empty cell.
124
- first_cell = s.strip("|").split("|")[0].strip()
125
+ cells = [c.strip() for c in s.strip("|").split("|")]
126
+ first_cell = cells[0]
125
127
  if first_cell.lower() == "step":
126
128
  continue
127
129
  if set(first_cell) <= set("-: "):
128
130
  continue
129
- count += 1
130
- return count
131
+ rows.append(cells)
132
+ return rows
133
+
134
+
135
+ def _count_effective_steps(section: str) -> int:
136
+ return len(_effective_step_rows(section))
131
137
 
132
138
 
133
139
  def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[ValidationError]:
@@ -159,6 +165,45 @@ def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[Valida
159
165
  return errs
160
166
 
161
167
 
168
+ SLICE_VALUE = re.compile(r"^\s*Slice value\s*:\s*(.+?)\s*$", re.M)
169
+ ACCEPTANCE = re.compile(r"^\s*Acceptance\s*:\s*(.+?)\s*$", re.M)
170
+ TDD_EXEMPTION = re.compile(r"^\s*TDD exemption\s*:\s*\S", re.M)
171
+
172
+
173
+ def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError]:
174
+ """S10: each stage declares a vertical slice and follows RED→GREEN ordering.
175
+
176
+ S10a — `Slice value:` line with a non-empty value.
177
+ S10b — `Acceptance:` line with a non-empty value.
178
+ S10c — first effective Stepwise step's action starts with `RED:` AND some
179
+ action starts with `GREEN:`, OR a `TDD exemption:` line is present.
180
+ """
181
+ errs: List[ValidationError] = []
182
+ for s in stages:
183
+ section = _slice_stage_section(text, s.stage_number)
184
+ if not section:
185
+ continue # S3 already reported the missing section
186
+
187
+ if not SLICE_VALUE.search(section):
188
+ errs.append(ValidationError("S10", s.stage_number,
189
+ "S10a: 'Slice value:' line missing or empty"))
190
+ if not ACCEPTANCE.search(section):
191
+ errs.append(ValidationError("S10", s.stage_number,
192
+ "S10b: 'Acceptance:' line missing or empty"))
193
+
194
+ if TDD_EXEMPTION.search(section):
195
+ continue
196
+ rows = _effective_step_rows(section)
197
+ actions = [r[1] for r in rows if len(r) > 1]
198
+ first_is_red = bool(actions) and actions[0].startswith("RED:")
199
+ has_green = any(a.startswith("GREEN:") for a in actions)
200
+ if not (first_is_red and has_green):
201
+ errs.append(ValidationError("S10", s.stage_number,
202
+ "S10c: first step action must start with 'RED:' and some "
203
+ "step action with 'GREEN:', or add a 'TDD exemption:' line"))
204
+ return errs
205
+
206
+
162
207
  def _check_depends_on(stages: List[StageMeta]) -> List[ValidationError]:
163
208
  errs: List[ValidationError] = []
164
209
  valid = {s.stage_number for s in stages}
@@ -229,7 +274,7 @@ def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[Validatio
229
274
 
230
275
 
231
276
  def collect_validation_errors(text: str) -> List[ValidationError]:
232
- """All S1–S9 checks against the report text; empty list means valid.
277
+ """All S1–S10 checks against the report text; empty list means valid.
233
278
 
234
279
  S1 (missing `## 5.5 Stage Map` heading) makes the rest unparseable, so it
235
280
  short-circuits. Shared by `main()` (CLI / implementation entry) and the
@@ -244,6 +289,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
244
289
  errors.extend(s2_errs)
245
290
  if stages:
246
291
  errors.extend(_check_each_stage_section(text, stages))
292
+ errors.extend(_check_slice_tdd(text, stages))
247
293
  errors.extend(_check_depends_on(stages))
248
294
  errors.extend(_check_parallel_safety(text, stages))
249
295
  return errors
@@ -1,12 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
- import { resolvePaths } from "./paths.mjs";
2
+ import { buildPythonpath, resolvePaths } from "./paths.mjs";
3
3
 
4
4
  export async function runPythonSnippet({ script, args = [], extraEnv = {} }) {
5
5
  const paths = await resolvePaths();
6
6
  return new Promise((resolve) => {
7
7
  const child = spawn("python3", ["-c", script, ...args], {
8
8
  stdio: ["ignore", "pipe", "pipe"],
9
- env: { ...process.env, PYTHONPATH: paths.pythonpath, ...extraEnv },
9
+ env: { ...process.env, PYTHONPATH: buildPythonpath(paths), ...extraEnv },
10
10
  });
11
11
  let stdout = "";
12
12
  let stderr = "";
@@ -22,7 +22,7 @@ export async function runPythonModule({ module, args = [], extraEnv = {}, stdio
22
22
  return new Promise((resolve) => {
23
23
  const child = spawn("python3", ["-m", module, ...args], {
24
24
  stdio: stdio === "capture" ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
25
- env: { ...process.env, PYTHONPATH: paths.pythonpath, ...extraEnv },
25
+ env: { ...process.env, PYTHONPATH: buildPythonpath(paths), ...extraEnv },
26
26
  });
27
27
  let stdout = "";
28
28
  let stderr = "";
@@ -0,0 +1,27 @@
1
+ import { runPythonModule } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra context-cost — estimate context/read cost for a task bundle
4
+
5
+ Usage:
6
+ okstra context-cost <task-root>
7
+ okstra context-cost <task-key> --project-root <dir>
8
+ okstra context-cost <task-key> --cwd <dir>
9
+
10
+ Output: JSON with task file counts, current-run counts, lead Phase 1 read
11
+ surface, analysis-worker initial read surface, and report-writer synthesis
12
+ surface. This is read-only; it never mutates task artifacts.
13
+ `;
14
+
15
+ export async function run(args) {
16
+ if (args.includes("--help") || args.includes("-h")) {
17
+ process.stdout.write(USAGE);
18
+ return 0;
19
+ }
20
+
21
+ const result = await runPythonModule({
22
+ module: "okstra_ctl.context_cost",
23
+ args,
24
+ stdio: "inherit-stdout",
25
+ });
26
+ return result.code ?? 0;
27
+ }
package/src/install.mjs CHANGED
@@ -22,6 +22,7 @@ const BIN_ENTRYPOINTS = [
22
22
  "okstra-codex-exec.sh",
23
23
  "okstra-gemini-exec.sh",
24
24
  "okstra-trace-cleanup.sh",
25
+ "okstra-team-reconcile.sh",
25
26
  "okstra-central.sh",
26
27
  "okstra-token-usage.py",
27
28
  "okstra-error-log.py",
package/src/memory.mjs CHANGED
@@ -46,8 +46,11 @@ It is separate from project-local .okstra task artifacts.
46
46
 
47
47
  Usage:
48
48
  okstra memory add [--content <text> | --file <path>] [options]
49
- okstra memory list [--limit <n>] [--tag <tag>] [--type <type>] [--json]
50
- okstra memory search <query> [--limit <n>] [--include-archived] [--json]
49
+ okstra memory list [--limit <n>] [--tag <tag>] [--type <type>]
50
+ [--project-group <name>] [--json]
51
+ okstra memory search <query> [--limit <n>] [--project-group <name>]
52
+ [--include-archived] [--json]
53
+ okstra memory groups [--include-archived] [--json]
51
54
  okstra memory show <id> [--json]
52
55
  okstra memory archive <id> [--json]
53
56
 
@@ -55,7 +58,7 @@ Add options:
55
58
  --title <title> Entry title. Defaults to the first non-empty line.
56
59
  --type <type> context|decision|preference|requirement|person|
57
60
  project-hint|follow-up. Default: context.
58
- --scope <scope> Free-form scope label. Default: global.
61
+ --project-group <name> Project group label (e.g. acme, private). Default: global.
59
62
  --project <id> Related project id. Repeatable.
60
63
  --tag <tag> Tag. Repeatable or comma-separated.
61
64
  --source <source> Source label. Default: conversation.
@@ -97,7 +100,7 @@ function parseAddArgs(args) {
97
100
  file: null,
98
101
  title: null,
99
102
  type: "context",
100
- scope: "global",
103
+ projectGroup: "global",
101
104
  source: "conversation",
102
105
  tags: [],
103
106
  projects: [],
@@ -110,7 +113,7 @@ function parseAddArgs(args) {
110
113
  else if (flag === "--file") opts.file = takeValue(args, i++, flag);
111
114
  else if (flag === "--title") opts.title = takeValue(args, i++, flag);
112
115
  else if (flag === "--type") opts.type = takeValue(args, i++, flag);
113
- else if (flag === "--scope") opts.scope = takeValue(args, i++, flag);
116
+ else if (flag === "--project-group") opts.projectGroup = takeValue(args, i++, flag);
114
117
  else if (flag === "--source") opts.source = takeValue(args, i++, flag);
115
118
  else if (flag === "--project") opts.projects.push(takeValue(args, i++, flag));
116
119
  else if (flag === "--tag") opts.tags.push(...splitCsv(takeValue(args, i++, flag)));
@@ -128,24 +131,26 @@ function parseAddArgs(args) {
128
131
  }
129
132
 
130
133
  function parseListArgs(args) {
131
- const opts = { ...parseGlobalFlags(args), limit: 20, tag: null, type: null };
134
+ const opts = { ...parseGlobalFlags(args), limit: 20, tag: null, type: null, projectGroup: null };
132
135
  for (let i = 0; i < args.length; i++) {
133
136
  const flag = args[i];
134
137
  if (flag === "--json" || flag === "--include-archived") continue;
135
138
  if (flag === "--limit") opts.limit = parseLimit(takeValue(args, i++, flag));
136
139
  else if (flag === "--tag") opts.tag = takeValue(args, i++, flag);
137
140
  else if (flag === "--type") opts.type = takeValue(args, i++, flag);
141
+ else if (flag === "--project-group") opts.projectGroup = takeValue(args, i++, flag);
138
142
  else throw new Error(`unknown flag ${flag}`);
139
143
  }
140
144
  return opts;
141
145
  }
142
146
 
143
147
  function parseQueryArgs(args) {
144
- const opts = { ...parseGlobalFlags(args), limit: 20, query: [] };
148
+ const opts = { ...parseGlobalFlags(args), limit: 20, query: [], projectGroup: null };
145
149
  for (let i = 0; i < args.length; i++) {
146
150
  const flag = args[i];
147
151
  if (flag === "--json" || flag === "--include-archived") continue;
148
152
  if (flag === "--limit") opts.limit = parseLimit(takeValue(args, i++, flag));
153
+ else if (flag === "--project-group") opts.projectGroup = takeValue(args, i++, flag);
149
154
  else if (flag.startsWith("--")) throw new Error(`unknown flag ${flag}`);
150
155
  else opts.query.push(flag);
151
156
  }
@@ -153,6 +158,19 @@ function parseQueryArgs(args) {
153
158
  return { ...opts, query: opts.query.join(" ").trim() };
154
159
  }
155
160
 
161
+ function parseGroupsArgs(args) {
162
+ for (const flag of args) {
163
+ if (flag !== "--json" && flag !== "--include-archived") {
164
+ throw new Error(`unknown flag ${flag}`);
165
+ }
166
+ }
167
+ return parseGlobalFlags(args);
168
+ }
169
+
170
+ function entryGroup(entry) {
171
+ return entry.projectGroup ?? "global";
172
+ }
173
+
156
174
  function parseLimit(raw) {
157
175
  const value = Number.parseInt(raw, 10);
158
176
  if (!Number.isInteger(value) || value < 1) {
@@ -220,7 +238,7 @@ function buildEntry(opts, content, now) {
220
238
  id,
221
239
  title,
222
240
  type: opts.type,
223
- scope: opts.scope,
241
+ projectGroup: opts.projectGroup,
224
242
  source: opts.source,
225
243
  tags: [...new Set(opts.tags)],
226
244
  relatedProjects: [...new Set(opts.projects)],
@@ -237,7 +255,7 @@ function renderMarkdown(entry, content) {
237
255
  `id: ${entry.id}`,
238
256
  `createdAt: ${entry.createdAt}`,
239
257
  `source: ${entry.source}`,
240
- `scope: ${entry.scope}`,
258
+ `project-group: ${entry.projectGroup}`,
241
259
  `type: ${entry.type}`,
242
260
  `status: ${entry.status}`,
243
261
  `relatedProjects: [${entry.relatedProjects.join(", ")}]`,
@@ -258,6 +276,7 @@ async function confirmSave(entry, content, opts) {
258
276
  process.stdout.write(`Memory Book entry:\n`);
259
277
  process.stdout.write(` title: ${entry.title}\n`);
260
278
  process.stdout.write(` type: ${entry.type}\n`);
279
+ process.stdout.write(` group: ${entry.projectGroup}\n`);
261
280
  process.stdout.write(` tags: ${entry.tags.join(", ") || "(none)"}\n`);
262
281
  process.stdout.write(` text: ${truncate(content.trim().replace(/\s+/g, " "), 140)}\n`);
263
282
  const rl = createInterface({ input, output });
@@ -341,6 +360,7 @@ async function opList(args) {
341
360
  const entries = visibleEntries(await readIndex(), opts)
342
361
  .filter((entry) => !opts.tag || entry.tags.includes(opts.tag))
343
362
  .filter((entry) => !opts.type || entry.type === opts.type)
363
+ .filter((entry) => !opts.projectGroup || entryGroup(entry) === opts.projectGroup)
344
364
  .slice(0, opts.limit);
345
365
  if (opts.json) emitJson(entries);
346
366
  else process.stdout.write(entries.map(formatEntryLine).join("\n") + (entries.length ? "\n" : ""));
@@ -350,7 +370,9 @@ async function opList(args) {
350
370
  async function opSearch(args) {
351
371
  const opts = parseQueryArgs(args);
352
372
  const needle = opts.query.toLowerCase();
353
- const entries = visibleEntries(await readIndex(), opts);
373
+ const entries = visibleEntries(await readIndex(), opts).filter(
374
+ (entry) => !opts.projectGroup || entryGroup(entry) === opts.projectGroup,
375
+ );
354
376
  const matches = [];
355
377
  for (const entry of entries) {
356
378
  if (await entryMatches(entry, needle)) matches.push(entry);
@@ -361,12 +383,27 @@ async function opSearch(args) {
361
383
  return 0;
362
384
  }
363
385
 
386
+ async function opGroups(args) {
387
+ const opts = parseGroupsArgs(args);
388
+ const counts = new Map();
389
+ for (const entry of visibleEntries(await readIndex(), opts)) {
390
+ const group = entryGroup(entry);
391
+ counts.set(group, (counts.get(group) ?? 0) + 1);
392
+ }
393
+ const groups = [...counts.entries()]
394
+ .map(([name, count]) => ({ name, count }))
395
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
396
+ if (opts.json) emitJson(groups);
397
+ else process.stdout.write(groups.map((g) => `${g.name} (${g.count})`).join("\n") + (groups.length ? "\n" : ""));
398
+ return 0;
399
+ }
400
+
364
401
  async function entryMatches(entry, needle) {
365
402
  const haystack = [
366
403
  entry.id,
367
404
  entry.title,
368
405
  entry.type,
369
- entry.scope,
406
+ entryGroup(entry),
370
407
  entry.source,
371
408
  ...entry.tags,
372
409
  ...entry.relatedProjects,
@@ -446,6 +483,8 @@ export async function run(args) {
446
483
  return await opList(rest);
447
484
  case "search":
448
485
  return await opSearch(rest);
486
+ case "groups":
487
+ return await opGroups(rest);
449
488
  case "show":
450
489
  return await opShow(rest);
451
490
  case "archive":
package/src/uninstall.mjs CHANGED
@@ -8,6 +8,7 @@ const BIN_ENTRYPOINTS = [
8
8
  "okstra-codex-exec.sh",
9
9
  "okstra-gemini-exec.sh",
10
10
  "okstra-trace-cleanup.sh",
11
+ "okstra-team-reconcile.sh",
11
12
  "okstra-central.sh",
12
13
  "okstra-token-usage.py",
13
14
  "okstra-error-log.py",