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.
- package/README.kr.md +1 -1
- package/README.md +1 -1
- package/docs/kr/architecture.md +1 -0
- package/docs/kr/cli.md +2 -1
- package/docs/superpowers/plans/2026-06-06-final-verification-whole-task-gate.md +993 -0
- package/docs/superpowers/plans/2026-06-06-stage-parallel-and-pending-fixes.md +93 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p1.md +447 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p2.md +289 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p3.md +774 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p4.md +303 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p5-multidep-base.md +387 -0
- package/docs/superpowers/specs/2026-06-06-final-verification-whole-task-gate-design.md +126 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +180 -0
- package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +16 -9
- package/runtime/prompts/profiles/_implementation-verifier.md +4 -1
- package/runtime/prompts/profiles/final-verification.md +7 -7
- package/runtime/prompts/profiles/implementation-planning.md +14 -7
- package/runtime/prompts/wizard/prompts.ko.json +3 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +14 -2
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/run.py +541 -41
- package/runtime/python/okstra_ctl/wizard.py +25 -7
- package/runtime/python/okstra_ctl/worktree.py +126 -9
- package/runtime/python/okstra_ctl/worktree_registry.py +88 -17
- package/runtime/schemas/final-report-v1.0.schema.json +36 -0
- package/runtime/skills/okstra-convergence/SKILL.md +14 -3
- package/runtime/skills/okstra-memory/SKILL.md +28 -5
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/templates/reports/final-report.template.md +12 -0
- package/runtime/templates/reports/final-verification-input.template.md +8 -5
- package/runtime/templates/reports/i18n/en.json +3 -1
- package/runtime/templates/reports/i18n/ko.json +3 -1
- package/runtime/validators/validate-implementation-plan-stages.py +57 -11
- package/runtime/validators/validate-run.py +143 -1
- package/runtime/validators/validate-workflow.sh +6 -1
- 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
|
-
>
|
|
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.
|
|
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–
|
|
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..
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
130
|
-
return
|
|
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–
|
|
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 =
|
|
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
|
-
|
|
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>]
|
|
50
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
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 === "--
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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":
|