okstra 0.51.0 → 0.53.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 (44) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/kr/architecture.md +1 -0
  4. package/docs/kr/cli.md +2 -1
  5. package/docs/superpowers/plans/2026-06-06-final-verification-whole-task-gate.md +993 -0
  6. package/docs/superpowers/plans/2026-06-06-stage-parallel-and-pending-fixes.md +93 -0
  7. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p1.md +447 -0
  8. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p2.md +289 -0
  9. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p3.md +774 -0
  10. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p4.md +303 -0
  11. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p5-multidep-base.md +387 -0
  12. package/docs/superpowers/specs/2026-06-06-final-verification-whole-task-gate-design.md +126 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +180 -0
  14. package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/report-writer-worker.md +1 -0
  18. package/runtime/bin/lib/okstra/cli.sh +5 -1
  19. package/runtime/bin/okstra.sh +1 -0
  20. package/runtime/prompts/launch.template.md +1 -0
  21. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  22. package/runtime/prompts/profiles/_implementation-executor.md +16 -9
  23. package/runtime/prompts/profiles/_implementation-verifier.md +4 -1
  24. package/runtime/prompts/profiles/final-verification.md +7 -7
  25. package/runtime/prompts/profiles/implementation-planning.md +14 -7
  26. package/runtime/prompts/wizard/prompts.ko.json +3 -2
  27. package/runtime/python/okstra_ctl/analysis_packet.py +14 -2
  28. package/runtime/python/okstra_ctl/render.py +3 -0
  29. package/runtime/python/okstra_ctl/run.py +541 -41
  30. package/runtime/python/okstra_ctl/wizard.py +25 -7
  31. package/runtime/python/okstra_ctl/worktree.py +126 -9
  32. package/runtime/python/okstra_ctl/worktree_registry.py +88 -17
  33. package/runtime/schemas/final-report-v1.0.schema.json +36 -0
  34. package/runtime/skills/okstra-convergence/SKILL.md +14 -3
  35. package/runtime/skills/okstra-memory/SKILL.md +28 -5
  36. package/runtime/skills/okstra-run/SKILL.md +1 -1
  37. package/runtime/templates/reports/final-report.template.md +12 -0
  38. package/runtime/templates/reports/final-verification-input.template.md +8 -5
  39. package/runtime/templates/reports/i18n/en.json +3 -1
  40. package/runtime/templates/reports/i18n/ko.json +3 -1
  41. package/runtime/validators/validate-implementation-plan-stages.py +57 -11
  42. package/runtime/validators/validate-run.py +143 -1
  43. package/runtime/validators/validate-workflow.sh +6 -1
  44. package/src/memory.mjs +50 -11
@@ -29,15 +29,18 @@ taskType: "{{FM_TASK_TYPE}}"
29
29
  - What was supposed to be delivered?
30
30
  - What is the intended acceptance decision?
31
31
 
32
+ ## 검증 모드
33
+
34
+ - 기본은 **전체-task** 검증입니다(`--stage auto`): 모든 Stage Map stage 가 구현·머지된 뒤 한 번 실행합니다.
35
+ - 특정 stage 만 격리 검증하려면 `--stage N` 으로 **단독-stage** 모드를 씁니다(release-handoff 진입 불가, 부분 검증).
36
+ - worktree / base / head 는 okstra 가 registry 와 `consumers.jsonl` 에서 자동 해소하므로 이 입력서에 수동 기입하지 않습니다.
37
+
32
38
  ## Source Implementation Report
33
39
 
34
40
  - Path (project-relative) to the originating `implementation` final-report:
35
- - Worktree / checkout path that final-verification must inspect:
36
- - Implementation base ref (`<base>` for `git diff --stat <base>..HEAD`):
37
- - Implementation head SHA expected at verification start:
38
41
  - Quoted `Commit list` / `Diff summary` excerpt from the implementation report:
39
42
 
40
- > If this section is empty, points to a missing report, or names a checkout that does not match the implementation report's commit list / diff summary, final-verification MUST end with status `blocked` and route back to `implementation` or `implementation-planning`. Do not verify an ambiguous target.
43
+ > 보고서 경로가 비거나 누락된 보고서를 가리키면 final-verification status `blocked` 으로 끝내고 `implementation` 또는 `implementation-planning` 으로 라우팅합니다. 검증 대상(worktree/base/head)은 okstra 가 자동 해소하므로 수동 기입이 어긋나 막히는 일은 없습니다.
41
44
 
42
45
  ## Requirement Coverage Source
43
46
 
@@ -87,7 +90,7 @@ taskType: "{{FM_TASK_TYPE}}"
87
90
 
88
91
  ## Questions for Analysers
89
92
 
90
- 1. Does the verification target (head SHA / diff stat) match the implementation report's commit list and diff summary?
93
+ 1. Did your analysis run against the injected `VERIFICATION_TARGET` (base / head SHA / worktree), and does the diff at that target fully cover the stage(s) under verification? (A head you cannot confirm against the injected target is a `tool-failure`, not a silent proceed.)
91
94
  2. For each requirement / acceptance criterion, what exact artifact (commit SHA, test output, log line, config value) proves coverage?
92
95
  3. Are there any acceptance blockers?
93
96
  4. What residual risks remain?
@@ -134,6 +134,8 @@
134
134
  },
135
135
  "finalVerification": {
136
136
  "validationEvidenceAside": "requirements coverage",
137
- "columnRequirement": "Requirement (plan/brief citation)"
137
+ "columnRequirement": "Requirement (plan/brief citation)",
138
+ "verificationScope": "Verification scope",
139
+ "stageReportsLabel": "Source implementation reports (per stage)"
138
140
  }
139
141
  }
@@ -134,6 +134,8 @@
134
134
  },
135
135
  "finalVerification": {
136
136
  "validationEvidenceAside": "요구사항 커버리지",
137
- "columnRequirement": "Requirement (plan/brief 인용)"
137
+ "columnRequirement": "Requirement (plan/brief 인용)",
138
+ "verificationScope": "검증 범위",
139
+ "stageReportsLabel": "stage 별 구현 리포트"
138
140
  }
139
141
  }
@@ -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
@@ -313,6 +313,18 @@ def extract_contract(
313
313
  }
314
314
 
315
315
 
316
+ def effective_run_task_type(run_manifest: dict, task_manifest: dict) -> str:
317
+ """Return the task type for the specific run being validated.
318
+
319
+ `task-manifest.json` is mutable lifecycle state and may point at a later
320
+ phase after the user has continued the task. A final-report belongs to the
321
+ immutable run-manifest, so run-manifest wins here.
322
+ """
323
+ return str(
324
+ run_manifest.get("taskType") or task_manifest.get("taskType") or ""
325
+ ).strip()
326
+
327
+
316
328
  def validate_team_state(
317
329
  team_state: dict, project_root: Path, contract: dict, failures: list[str]
318
330
  ) -> None:
@@ -857,6 +869,7 @@ PLANNING_REQUIRED_SECTIONS = (
857
869
  "Dependency",
858
870
  "Validation Checklist",
859
871
  "Rollback",
872
+ "Requirement Coverage",
860
873
  "Plan Body Verification",
861
874
  )
862
875
 
@@ -951,6 +964,11 @@ _APPROVED_FRONTMATTER_RE = re.compile(
951
964
  re.IGNORECASE | re.MULTILINE,
952
965
  )
953
966
  _FRONTMATTER_BLOCK_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
967
+ _REQUIREMENT_COVERAGE_HEADING_RE = re.compile(
968
+ r"^###[ \t]+(?:5\.5\.8[ \t]+)?Requirement Coverage\b",
969
+ re.IGNORECASE | re.MULTILINE,
970
+ )
971
+ _NEXT_THIRD_LEVEL_HEADING_RE = re.compile(r"^###[ \t]+", re.MULTILINE)
954
972
 
955
973
 
956
974
  def _extract_final_verdict_token(content: str) -> str | None:
@@ -968,6 +986,115 @@ def _extract_final_verdict_token(content: str) -> str | None:
968
986
  return match.group("value")
969
987
 
970
988
 
989
+ def _split_markdown_row(line: str) -> list[str]:
990
+ stripped = line.strip()
991
+ if stripped.startswith("|"):
992
+ stripped = stripped[1:]
993
+ if stripped.endswith("|"):
994
+ stripped = stripped[:-1]
995
+ return [cell.strip().strip("`").strip() for cell in stripped.split("|")]
996
+
997
+
998
+ def _is_markdown_separator(line: str) -> bool:
999
+ stripped = line.strip()
1000
+ if not stripped.startswith("|"):
1001
+ return False
1002
+ for cell in stripped.strip("|").split("|"):
1003
+ if not re.fullmatch(r"\s*:?-{2,}:?\s*", cell):
1004
+ return False
1005
+ return True
1006
+
1007
+
1008
+ def _append_requirement_coverage_failures(
1009
+ content: str,
1010
+ gate_value: str,
1011
+ failures: list[str],
1012
+ ) -> None:
1013
+ """Validate implementation-planning §5.5.8 requirement coverage.
1014
+
1015
+ The table is intentionally lightweight: it cannot prove semantic truth, but
1016
+ it makes requirement-to-plan mapping explicit and blocks publishable plans
1017
+ that admit uncovered requirements.
1018
+ """
1019
+ heading = _REQUIREMENT_COVERAGE_HEADING_RE.search(content)
1020
+ if heading is None:
1021
+ failures.append(
1022
+ "implementation-planning report is missing `Requirement Coverage` "
1023
+ "section — every task-brief requirement must map to option/stage/step."
1024
+ )
1025
+ return
1026
+
1027
+ rest = content[heading.end():]
1028
+ next_heading = _NEXT_THIRD_LEVEL_HEADING_RE.search(rest)
1029
+ section = rest[: next_heading.start()] if next_heading else rest
1030
+ lines = section.splitlines()
1031
+
1032
+ header_idx = -1
1033
+ headers: list[str] = []
1034
+ for idx, line in enumerate(lines):
1035
+ if not line.lstrip().startswith("|"):
1036
+ continue
1037
+ cells = [c.lower() for c in _split_markdown_row(line)]
1038
+ if "id" in cells and "requirement" in cells and "status" in cells:
1039
+ header_idx = idx
1040
+ headers = cells
1041
+ break
1042
+ if header_idx < 0:
1043
+ failures.append(
1044
+ "implementation-planning Requirement Coverage section has no table "
1045
+ "with `ID`, `Requirement`, and `Status` columns."
1046
+ )
1047
+ return
1048
+
1049
+ id_col = headers.index("id")
1050
+ status_col = headers.index("status")
1051
+ rows: list[tuple[str, str]] = []
1052
+ body_started = False
1053
+ for line in lines[header_idx + 1:]:
1054
+ if not line.lstrip().startswith("|"):
1055
+ if body_started:
1056
+ break
1057
+ continue
1058
+ if _is_markdown_separator(line):
1059
+ body_started = True
1060
+ continue
1061
+ if not body_started:
1062
+ continue
1063
+ cells = _split_markdown_row(line)
1064
+ if max(id_col, status_col) >= len(cells):
1065
+ failures.append(
1066
+ "implementation-planning Requirement Coverage table has a "
1067
+ f"malformed row: `{line.strip()}`"
1068
+ )
1069
+ continue
1070
+ rows.append((cells[id_col], cells[status_col].lower()))
1071
+
1072
+ if not rows:
1073
+ failures.append(
1074
+ "implementation-planning Requirement Coverage table has no data rows."
1075
+ )
1076
+ return
1077
+
1078
+ for row_id, status in rows:
1079
+ if not re.fullmatch(r"covered|gap|blocked C-\d{3,}", status):
1080
+ failures.append(
1081
+ "implementation-planning Requirement Coverage row "
1082
+ f"`{row_id}` has invalid Status `{status}`; expected "
1083
+ "`covered`, `gap`, or `blocked C-NNN`."
1084
+ )
1085
+
1086
+ if gate_value in ("passed", "passed-with-dissent"):
1087
+ uncovered = [
1088
+ f"{row_id} ({status})"
1089
+ for row_id, status in rows
1090
+ if status != "covered"
1091
+ ]
1092
+ if uncovered:
1093
+ failures.append(
1094
+ "implementation-planning Gate result is publishable but "
1095
+ "Requirement Coverage has uncovered row(s): "
1096
+ + ", ".join(uncovered)
1097
+ )
971
1098
  def _extract_verdict_card_token(content: str) -> str | None:
972
1099
  """Return the `Verdict Token` cell from the Verdict Card block."""
973
1100
  block = _VERDICT_CARD_BLOCK_RE.search(content)
@@ -1101,6 +1228,19 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
1101
1228
  "when the verdict is `accepted`."
1102
1229
  )
1103
1230
 
1231
+ scope = data.get("verificationScope", "whole-task")
1232
+ if scope not in ("whole-task", "single-stage"):
1233
+ failures.append(
1234
+ f"final-verification: verificationScope must be `whole-task` or "
1235
+ f"`single-stage`, got {scope!r}."
1236
+ )
1237
+ if scope == "single-stage" and "release-handoff" in routing:
1238
+ failures.append(
1239
+ "final-verification: verificationScope `single-stage` cannot recommend "
1240
+ "release-handoff routing — single-stage is a partial verification and "
1241
+ "release-handoff requires whole-task verification."
1242
+ )
1243
+
1104
1244
 
1105
1245
  def validate_report_views(report_path: Path, failures: list[str]) -> None:
1106
1246
  """Enforce Phase 7 step 1.5 (BLOCKING) — the self-contained HTML
@@ -1292,6 +1432,8 @@ def validate_phase_boundary(
1292
1432
  "must NOT publish a pre-approved plan when verification did not pass."
1293
1433
  )
1294
1434
 
1435
+ _append_requirement_coverage_failures(content, gate_value, failures)
1436
+
1295
1437
  # Only a publishable plan (gate passed) can be flipped to `approved: true`
1296
1438
  # and reach the `implementation` entry, so the Stage Map structure is
1297
1439
  # enforced only here — a blocked/aborted plan may legitimately be incomplete.
@@ -1732,7 +1874,7 @@ def main() -> int:
1732
1874
  validate_report(report_path, contract["required_agent_status_entries"], failures)
1733
1875
  validate_team_state_usage(team_state, failures)
1734
1876
 
1735
- task_type = str(task_manifest.get("taskType") or run_manifest.get("taskType") or "").strip()
1877
+ task_type = effective_run_task_type(run_manifest, task_manifest)
1736
1878
  validate_phase_boundary(task_type, report_path, failures)
1737
1879
  if task_type:
1738
1880
  validate_worker_results_audit(report_path, task_type, failures)
@@ -17,7 +17,12 @@ WORKSPACE_APP_PATH="$PROJECT_ROOT"
17
17
  OKSTRA_SCRIPT="$WORKSPACE_ROOT/scripts/okstra.sh"
18
18
  RUN_VALIDATOR_PATH="$WORKSPACE_ROOT/validators/validate-run.py"
19
19
  SOURCE_ASSET_ROOT="$WORKSPACE_ROOT/agents"
20
- TASK_TYPE="final-verification"
20
+ # Arbitrary sample task-type used only to exercise the bundle-prep /
21
+ # discovery-pointer / task-catalog / asset-seeding machinery via render-only
22
+ # run_okstra. Must NOT require --approved-plan (excludes implementation and
23
+ # final-verification) and must have a tests/fixtures/final-report-data/
24
+ # <task-type>-001.data.json sample (used by prepare_run_validator_fixture).
25
+ TASK_TYPE="requirements-discovery"
21
26
  PRIMARY_TASK_GROUP="validation"
22
27
  PRIMARY_TASK_ID="asset-refresh-and-reference-expectations"
23
28
  PRIMARY_BRIEF_FILENAME="validation-brief-primary.md"
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":