okstra 0.117.0 → 0.119.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.md +115 -104
- package/docs/architecture/storage-model.md +300 -0
- package/docs/architecture.md +802 -0
- package/docs/cli.md +658 -0
- package/docs/container.md +124 -0
- package/docs/contributor-change-matrix.md +5 -4
- package/docs/follow-ups/2026-07-10-final-report-option-3.md +51 -0
- package/docs/performance-improvement-plan-v2.md +374 -0
- package/docs/project-structure-overview.md +21 -10
- package/package.json +10 -5
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +5 -35
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/python/okstra_ctl/clarification_items.py +48 -0
- package/runtime/python/okstra_ctl/recap.py +80 -3
- package/runtime/python/okstra_ctl/report_views.py +46 -6
- package/runtime/python/okstra_ctl/user_response.py +190 -0
- package/runtime/skills/okstra-inspect/SKILL.md +37 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +9 -8
- package/runtime/skills/okstra-user-response/SKILL.md +121 -0
- package/runtime/templates/reports/report.js +6 -3
- package/runtime/templates/reports/user-response.template.md +1 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/inspect/recap.mjs +6 -1
- package/src/commands/inspect/user-response.mjs +26 -0
- package/src/lib/skill-catalog.mjs +1 -0
- package/README.kr.md +0 -231
- package/docs/kr/architecture/storage-model.md +0 -297
- package/docs/kr/architecture.md +0 -815
- package/docs/kr/cli.md +0 -657
- package/docs/kr/container.md +0 -122
- package/docs/kr/follow-ups/2026-07-10-final-report-option-3.md +0 -51
- package/docs/kr/performance-improvement-plan-v2.md +0 -374
- package/docs/kr/performance-improvement-plan.md +0 -147
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Okstra — 폴더 구조 및 기능 요약
|
|
2
2
|
|
|
3
|
-
> 현재 저장소 기준의 living map입니다. 빠른 사용법은 [`README.
|
|
3
|
+
> 현재 저장소 기준의 living map입니다. 빠른 사용법은 [`README.md`](../README.md), 내부 실행 계약은 [`docs/architecture.md`](architecture.md), 저장 구조는 [`docs/architecture/storage-model.md`](architecture/storage-model.md), CLI 세부 옵션은 [`docs/cli.md`](cli.md)를 함께 보세요.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -54,19 +54,21 @@ okstra/
|
|
|
54
54
|
│ ├── okstra_vendor/ vendored Jinja2 / MarkupSafe + graphify / networkx (okstra-graphify skill)
|
|
55
55
|
│ ├── lib/okstra/ Bash helpers for okstra.sh
|
|
56
56
|
│ └── lib/okstra-ctl/ Bash control-center subcommands
|
|
57
|
-
├── skills/ Claude Code skills (
|
|
57
|
+
├── skills/ Claude Code skills (11)
|
|
58
58
|
├── agents/ lead SKILL.md + worker agent specs
|
|
59
59
|
├── prompts/ launch template, phase profiles, wizard prompt JSON
|
|
60
60
|
├── schemas/ JSON schema for final-report data.json
|
|
61
61
|
├── templates/ report, setup, PR, project-doc templates/assets
|
|
62
62
|
├── validators/ run / brief / schedule / view validators
|
|
63
63
|
├── tools/build.mjs source → runtime sync
|
|
64
|
+
├── tools/docs_publication/ public-manual publication pipeline (build-time only; not shipped)
|
|
65
|
+
├── config/docs-publication.json publication inventory: every tracked Markdown path + classification
|
|
64
66
|
├── runtime/ generated install payload; do not edit directly
|
|
65
67
|
├── tests/ pytest unit suite
|
|
66
68
|
├── tests-e2e/ Bash end-to-end scenarios
|
|
67
69
|
├── docs/ architecture, CLI, plans/specs
|
|
68
70
|
├── package.json npm metadata and published file list
|
|
69
|
-
├── README.md
|
|
71
|
+
├── README.md public user manual
|
|
70
72
|
├── CHANGES.md / CHANGELOG.md human-authored and generated changelogs
|
|
71
73
|
└── RELEASING.md release process
|
|
72
74
|
```
|
|
@@ -84,9 +86,13 @@ okstra/
|
|
|
84
86
|
- `bin/`
|
|
85
87
|
- `src/`
|
|
86
88
|
- `runtime/`
|
|
87
|
-
- `docs
|
|
89
|
+
- `docs/architecture.md`
|
|
90
|
+
- `docs/architecture/`
|
|
91
|
+
- `docs/cli.md`
|
|
92
|
+
- `docs/container.md`
|
|
93
|
+
- `docs/performance-improvement-plan-v2.md`
|
|
94
|
+
- selected contributor, follow-up, AI, and task-process documentation paths
|
|
88
95
|
- `README.md`
|
|
89
|
-
- `README.kr.md`
|
|
90
96
|
|
|
91
97
|
So `runtime/` is not the only npm-published content. It is the install payload copied into `~/.okstra`.
|
|
92
98
|
|
|
@@ -142,7 +148,7 @@ Runtime/install asset changes follow this checklist:
|
|
|
142
148
|
|
|
143
149
|
`bin/okstra` is a thin dynamic-import router. Every command module exports `run(args) -> Promise<number>` or a named install/uninstall runner.
|
|
144
150
|
|
|
145
|
-
`src/` is layered: the dispatch table (`src/cli-registry.mjs`) stays at the root, shared infrastructure with no command export lives under `src/lib/`, and command modules are grouped by domain under `src/commands/{lifecycle,execute,inspect,report,memory}/`.
|
|
151
|
+
`src/` is layered: the dispatch table (`src/cli-registry.mjs`) stays at the root, shared infrastructure with no command export lives under `src/lib/`, and command modules are grouped by domain under `src/commands/{lifecycle,execute,inspect,report,memory,pr}/`.
|
|
146
152
|
|
|
147
153
|
| Command | Module | Role |
|
|
148
154
|
|---|---|---|
|
|
@@ -175,6 +181,7 @@ Runtime/install asset changes follow this checklist:
|
|
|
175
181
|
| `token-usage` | `src/commands/execute/token-usage.mjs` | Wrap installed Python token usage CLI |
|
|
176
182
|
| `spawn-followups`, `error-log` | `src/commands/execute/*.mjs` | Follow-up task bundle creation and run error-log append helpers |
|
|
177
183
|
| `memory` | `src/commands/memory/memory.mjs` | Store/find global conversation memory under `~/.okstra/memory-book` |
|
|
184
|
+
| `pr` | `src/commands/pr/pr.mjs` | `okstra pr <template\|branches\|gen>` — PR body template store under `~/.okstra/template/pr/` (bundled fallback `src/commands/pr/default.md`), base-branch recommendation, and the `gen` JSON bundle (template + `<base>..HEAD` commits + `<base>...HEAD` diffstat) backing the okstra-pr-gen skill. Git-only; no project registration required |
|
|
178
185
|
| `recap` | `src/commands/inspect/recap.mjs` | `okstra recap <assemble\|record>` Node wrapper backing the okstra-inspect `recap` facet |
|
|
179
186
|
| `rollup` | `src/commands/inspect/rollup.mjs` | `okstra rollup` thin shim into `scripts/okstra_ctl/rollup.py` — read-only cross-task roll-up backing the okstra-rollup skill |
|
|
180
187
|
| `container` | `src/commands/inspect/container.mjs` | `bin okstra container` thin shim into `scripts/okstra_ctl/container.py` for the okstra-container-build skill |
|
|
@@ -313,7 +320,7 @@ Optional (v1.0 하위호환) 최상위 키:
|
|
|
313
320
|
|
|
314
321
|
### 4.10 `skills/`
|
|
315
322
|
|
|
316
|
-
|
|
323
|
+
11 user-facing skills (the only skills `okstra install` copies). The list SSOT is `USER_SKILL_NAMES` in `src/lib/skill-catalog.mjs`; the Claude plugin manifest and the installer both derive from it:
|
|
317
324
|
|
|
318
325
|
| Skill | User-invocable | Role |
|
|
319
326
|
|---|---:|---|
|
|
@@ -324,6 +331,8 @@ Optional (v1.0 하위호환) 최상위 키:
|
|
|
324
331
|
| `okstra-rollup` | yes | Cross-task roll-up — aggregate runs/time/errors across a task-group (or whole project) and synthesize a digest from the report files |
|
|
325
332
|
| `okstra-schedule` | yes | Generate task-group schedule |
|
|
326
333
|
| `okstra-container-build` | yes | Non-linear deploy tool — deploy a verified task's code as a docker compose group and watch each container (sub-commands `up` / `status` / `logs` / `stop-watcher` / `down`) |
|
|
334
|
+
| `okstra-graphify` | yes | Knowledge graph over the project's own `.okstra/` memory — final reports, `decisions/*.md`, `glossary.md`; scope restricted to `.okstra/`, output under `.okstra/graph/` (sub-commands `build` / `query` / `path` / `explain` / `mcp` / `wiki`) |
|
|
335
|
+
| `okstra-pr-gen` | yes | Register PR body templates under `~/.okstra/template/pr/` and generate a PR description from a branch diff (drives `okstra pr template` / `branches` / `gen`). **Global skill** — needs a Git repo, not `<PROJECT_ROOT>/.okstra/project.json` |
|
|
327
336
|
| `okstra-manager` | yes | Coordinate cross-project okstra tasks through manager-owned plans, assignments, sync snapshots, status, and child launch context packets |
|
|
328
337
|
| `okstra-setup` | yes | Install/check runtime and register project |
|
|
329
338
|
|
|
@@ -488,11 +497,13 @@ Final report artifacts live under `runs/<task-type>/reports/`. Human responses f
|
|
|
488
497
|
|
|
489
498
|
When changing code, keep these docs in sync:
|
|
490
499
|
|
|
491
|
-
- New Node command: update `bin/okstra` usage, `README
|
|
500
|
+
- New Node command: update `bin/okstra` usage, `README.md`, this file, and `docs/cli.md`.
|
|
492
501
|
- New runtime source copied to users: update `tools/build.mjs`, install/uninstall manifests if applicable, and this file.
|
|
493
|
-
- New skill/agent: update `README
|
|
502
|
+
- New skill/agent: update `README.md`, this file, install/uninstall fallback lists, and `CHANGES.md`.
|
|
494
503
|
- New report field/section: update schema, template, report-writer worker, validator tests, this file's report model if user-visible.
|
|
495
|
-
- New phase/profile behavior: update `prompts/profiles/*`, `docs/
|
|
504
|
+
- New phase/profile behavior: update `prompts/profiles/*`, `docs/architecture.md`, `docs/cli.md`, and `README.md` if user-facing.
|
|
505
|
+
|
|
506
|
+
`README.md`, `docs/cli.md`, `docs/architecture.md`, `docs/architecture/storage-model.md`, `docs/container.md`, and `docs/performance-improvement-plan-v2.md` are `translated` outputs in `config/docs-publication.json` — do **not** hand-edit them. Author the Korean source under the maintainer's Git-ignored `.project-docs/ko-source/`, then run the publication pipeline (`python3 -m tools.docs_publication prepare|validate|apply|record`), which keeps the source/output hashes in `.project-docs/doc-publishing/state.json` in step. Files classified `pending` — including this one, `CHANGES.md`, and `CHANGELOG.md` — are edited directly.
|
|
496
507
|
|
|
497
508
|
---
|
|
498
509
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "okstra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.119.0",
|
|
4
4
|
"description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "devonshin",
|
|
@@ -16,14 +16,18 @@
|
|
|
16
16
|
"bin/",
|
|
17
17
|
"src/",
|
|
18
18
|
"runtime/",
|
|
19
|
+
"docs/architecture.md",
|
|
20
|
+
"docs/architecture/",
|
|
21
|
+
"docs/cli.md",
|
|
22
|
+
"docs/container.md",
|
|
19
23
|
"docs/contributor-change-matrix.md",
|
|
24
|
+
"docs/follow-ups/2026-07-10-final-report-option-3.md",
|
|
20
25
|
"docs/for-ai/",
|
|
21
|
-
"docs/
|
|
26
|
+
"docs/performance-improvement-plan-v2.md",
|
|
22
27
|
"docs/pr-template-usage.md",
|
|
23
28
|
"docs/project-structure-overview.md",
|
|
24
29
|
"docs/task-process/",
|
|
25
|
-
"README.md"
|
|
26
|
-
"README.kr.md"
|
|
30
|
+
"README.md"
|
|
27
31
|
],
|
|
28
32
|
"engines": {
|
|
29
33
|
"node": ">=18"
|
|
@@ -31,9 +35,10 @@
|
|
|
31
35
|
"scripts": {
|
|
32
36
|
"build": "node tools/build.mjs",
|
|
33
37
|
"prepack": "node tools/build.mjs",
|
|
38
|
+
"docs:validate": "python3 -m tools.docs_publication validate --repo .",
|
|
34
39
|
"test:js": "node --test tests-js/*.test.mjs",
|
|
35
40
|
"test:py": "python3 -m pytest tests/",
|
|
36
41
|
"test:workflow": "bash validators/validate-workflow.sh",
|
|
37
|
-
"check": "npm run build && npm run test:js && npm run test:py && npm run test:workflow"
|
|
42
|
+
"check": "npm run docs:validate && npm run build && npm run test:js && npm run test:py && npm run test:workflow"
|
|
38
43
|
}
|
|
39
44
|
}
|
package/runtime/BUILD.json
CHANGED
|
@@ -23,7 +23,6 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
import argparse
|
|
25
25
|
import os
|
|
26
|
-
import re
|
|
27
26
|
import sys
|
|
28
27
|
from pathlib import Path
|
|
29
28
|
|
|
@@ -43,7 +42,7 @@ if (SCRIPTS_DIR / "okstra_ctl" / "report_views.py").is_file():
|
|
|
43
42
|
elif HOME_LIB.is_dir() and str(HOME_LIB) not in sys.path:
|
|
44
43
|
sys.path.insert(0, str(HOME_LIB))
|
|
45
44
|
|
|
46
|
-
from okstra_ctl.report_views import
|
|
45
|
+
from okstra_ctl.report_views import infer_run_meta, render_html_view # noqa: E402
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
_OKSTRA_HOME = Path(os.environ.get("OKSTRA_HOME", str(Path.home() / ".okstra")))
|
|
@@ -60,15 +59,6 @@ _TEMPLATES_DIRS = (
|
|
|
60
59
|
_OKSTRA_HOME / "templates" / "reports",
|
|
61
60
|
)
|
|
62
61
|
|
|
63
|
-
# task-type itself can contain hyphens (``implementation-planning``,
|
|
64
|
-
# ``final-verification``, ``release-handoff``), so the filename segment
|
|
65
|
-
# between ``final-report-`` and ``-<seq>.md`` is matched greedily; the
|
|
66
|
-
# trailing ``-(?P<seq>\d+)\.md$`` anchor pins seq to the last numeric tail.
|
|
67
|
-
_PATH_RE = re.compile(
|
|
68
|
-
r"runs/(?P<task_type>[^/]+)/reports/final-report-.+-(?P<seq>\d+)\.md$"
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
|
|
72
62
|
def _load_assets() -> tuple[str, str]:
|
|
73
63
|
css_text: str | None = None
|
|
74
64
|
js_text: str | None = None
|
|
@@ -87,23 +77,6 @@ def _load_assets() -> tuple[str, str]:
|
|
|
87
77
|
return css_text, js_text
|
|
88
78
|
|
|
89
79
|
|
|
90
|
-
def _infer_from_path(path: Path) -> dict[str, str]:
|
|
91
|
-
posix = path.as_posix()
|
|
92
|
-
m = _PATH_RE.search(posix)
|
|
93
|
-
if m:
|
|
94
|
-
return {"task_type": m.group("task_type"), "seq": m.group("seq")}
|
|
95
|
-
return {}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _infer_from_body(text: str) -> dict[str, str]:
|
|
99
|
-
found: dict[str, str] = {}
|
|
100
|
-
for label, key in (("Task Key", "task_key"), ("Task Type", "task_type")):
|
|
101
|
-
m = re.search(rf"^- {label}:\s*(\S.*?)\s*$", text, re.MULTILINE)
|
|
102
|
-
if m:
|
|
103
|
-
found[key] = m.group(1)
|
|
104
|
-
return found
|
|
105
|
-
|
|
106
|
-
|
|
107
80
|
def main(argv: list[str] | None = None) -> int:
|
|
108
81
|
parser = argparse.ArgumentParser(
|
|
109
82
|
description="Render the self-contained HTML view of an okstra final-report."
|
|
@@ -119,14 +92,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
119
92
|
if not report_path.is_file():
|
|
120
93
|
parser.error(f"final-report not found: {report_path}")
|
|
121
94
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
source_report = args.source_report or report_path.name
|
|
127
|
-
|
|
95
|
+
meta = infer_run_meta(
|
|
96
|
+
report_path, task_key=args.task_key, task_type=args.task_type,
|
|
97
|
+
seq=args.seq, source_report=args.source_report,
|
|
98
|
+
)
|
|
128
99
|
css, js = _load_assets()
|
|
129
|
-
meta = RunMeta(task_key=task_key, task_type=task_type, seq=seq, source_report=source_report)
|
|
130
100
|
html_path = render_html_view(report_path, run_meta=meta, css=css, js=js)
|
|
131
101
|
if html_path is None:
|
|
132
102
|
print("html: skipped (no §1 clarification rows — html view carries no interactive forms for this report)")
|
|
@@ -85,6 +85,7 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
|
|
|
85
85
|
- Source path: `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}`
|
|
86
86
|
- If the source path above is empty, no prior clarification response was attached to this run.
|
|
87
87
|
- If the source path is set, a copy is staged at `{{INSTRUCTION_SET_RELATIVE_PATH}}/clarification-response.md`. Read it before running workers; reconcile each `C-*` row in section 1 (`## 1. Clarification Items`) of the prior report against new evidence and record the outcome in the conditional `## 0. Clarification Response Carried In From Previous Run` section of this run's final report (render that heading only when carry-in is non-empty — the validator fails empty Section 0 stubs).
|
|
88
|
+
- When a `## <C-id>` block carries `- Disposition: reframe`, it is not an answer but a request to re-question — the user is asking you to redefine this item. Do not treat it as answered; reconstruct the question itself in a fresh `## 1` Clarification Item, and never let a `reframe` item satisfy the approval gate.
|
|
88
89
|
|
|
89
90
|
### Incremental re-verification (implementation-planning clarification re-runs only)
|
|
90
91
|
|
|
@@ -208,6 +208,54 @@ def parse_clarification_items(report_text: str) -> Optional[list[ClarificationIt
|
|
|
208
208
|
return _walk_section_1_table(section).items
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
def parse_clarification_rows(report_text: str) -> list[dict]:
|
|
212
|
+
"""§1 행마다 메타(ClarificationItem) + 원문 Statement / Expected form 셀.
|
|
213
|
+
|
|
214
|
+
``parse_clarification_items`` 와 같은 lenient 계약: §1 부재 또는 헤더
|
|
215
|
+
미인식이면 ``[]``. §1 테이블 셀 추출의 단일 참조점 — 다른 모듈이 §1
|
|
216
|
+
레이아웃을 다시 훑지 않도록 이 함수로 통일한다.
|
|
217
|
+
"""
|
|
218
|
+
section = _section_1_slice(report_text)
|
|
219
|
+
if section is None:
|
|
220
|
+
return []
|
|
221
|
+
lines = section.splitlines()
|
|
222
|
+
header_idx = -1
|
|
223
|
+
for idx, line in enumerate(lines):
|
|
224
|
+
if not line.lstrip().startswith("|"):
|
|
225
|
+
continue
|
|
226
|
+
cells = [c.lower() for c in _split_pipe_row(line)]
|
|
227
|
+
if "user input" in cells and any(c.startswith("statement") for c in cells):
|
|
228
|
+
header_idx = idx
|
|
229
|
+
break
|
|
230
|
+
if header_idx < 0:
|
|
231
|
+
return []
|
|
232
|
+
header = [c.lower() for c in _split_pipe_row(lines[header_idx])]
|
|
233
|
+
s_col = next((j for j, h in enumerate(header) if h.startswith("statement")), -1)
|
|
234
|
+
e_col = next((j for j, h in enumerate(header) if h.startswith("expected form")), -1)
|
|
235
|
+
rows: list[dict] = []
|
|
236
|
+
body = False
|
|
237
|
+
for line in lines[header_idx + 1:]:
|
|
238
|
+
if not line.lstrip().startswith("|"):
|
|
239
|
+
if body:
|
|
240
|
+
break
|
|
241
|
+
continue
|
|
242
|
+
if is_separator_row(line):
|
|
243
|
+
body = True
|
|
244
|
+
continue
|
|
245
|
+
if not body:
|
|
246
|
+
continue
|
|
247
|
+
cells = _split_pipe_row(line)
|
|
248
|
+
item = parse_meta_cell(cells[0]) if cells else None
|
|
249
|
+
if item is None:
|
|
250
|
+
continue
|
|
251
|
+
rows.append({
|
|
252
|
+
"item": item,
|
|
253
|
+
"statement": cells[s_col] if 0 <= s_col < len(cells) else "",
|
|
254
|
+
"expected_form": cells[e_col] if 0 <= e_col < len(cells) else "",
|
|
255
|
+
})
|
|
256
|
+
return rows
|
|
257
|
+
|
|
258
|
+
|
|
211
259
|
UNRESOLVED_STATUSES = {"open", "answered"}
|
|
212
260
|
|
|
213
261
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""Read-side recap for a task: assemble run-to-run phase transitions
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
"""Read-side recap for a task: assemble run-to-run phase transitions, append a
|
|
2
|
+
recap Q&A log, and write agent-authored notes. Writes only under
|
|
3
|
+
<task-root>/recap/recap-log.jsonl and <task-root>/notes/; never touches
|
|
4
|
+
task-manifest / catalog / timeline.
|
|
4
5
|
"""
|
|
5
6
|
from __future__ import annotations
|
|
6
7
|
|
|
@@ -10,9 +11,12 @@ import json
|
|
|
10
11
|
import sys
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
14
|
+
from okstra_ctl.ids import slugify_task_segment
|
|
13
15
|
from okstra_ctl.run_context import dir_flock
|
|
14
16
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
15
17
|
|
|
18
|
+
NOTE_KINDS = ("verification-evidence", "decision-draft", "analysis-note")
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
def _load_timeline(task_root: Path) -> list[dict]:
|
|
18
22
|
path = task_root / "history" / "timeline.json"
|
|
@@ -91,6 +95,56 @@ def append_recap_entry(task_root, project_root, *, kind, mode, question,
|
|
|
91
95
|
return {"logPath": project_rel(log_path, project_root), "entry": entry}
|
|
92
96
|
|
|
93
97
|
|
|
98
|
+
def _note_path(notes_dir: Path, slug: str, date: str) -> Path:
|
|
99
|
+
# 같은 날 같은 slug 로 노트를 여러 개 남기면 덮어쓰지 않도록 -2, -3 을 붙인다.
|
|
100
|
+
base = notes_dir / f"{slug}-{date}.md"
|
|
101
|
+
if not base.exists():
|
|
102
|
+
return base
|
|
103
|
+
seq = 2
|
|
104
|
+
while (candidate := notes_dir / f"{slug}-{date}-{seq}.md").exists():
|
|
105
|
+
seq += 1
|
|
106
|
+
return candidate
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _note_frontmatter(*, task_key, kind, created_at, purpose, scope_note) -> str:
|
|
110
|
+
return (
|
|
111
|
+
"---\n"
|
|
112
|
+
f"task-key: {task_key}\n"
|
|
113
|
+
f"kind: {kind}\n"
|
|
114
|
+
"author: agent\n"
|
|
115
|
+
f"created-at: {created_at}\n"
|
|
116
|
+
f"purpose: {purpose}\n"
|
|
117
|
+
f"scope-note: {scope_note}\n"
|
|
118
|
+
"---\n\n"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_note(task_root, project_root, *, kind, slug, purpose, scope_note,
|
|
123
|
+
body, now) -> dict:
|
|
124
|
+
notes_dir = task_root / "notes"
|
|
125
|
+
notes_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
created_at = now.date().isoformat()
|
|
127
|
+
slug_seg = slugify_task_segment(slug)
|
|
128
|
+
if not slug_seg:
|
|
129
|
+
raise ValueError("slug must contain at least one alphanumeric character")
|
|
130
|
+
path = _note_path(notes_dir, slug_seg, created_at)
|
|
131
|
+
task_key = _task_key(task_root)
|
|
132
|
+
frontmatter = _note_frontmatter(
|
|
133
|
+
task_key=task_key, kind=kind, created_at=created_at,
|
|
134
|
+
purpose=purpose, scope_note=scope_note)
|
|
135
|
+
path.write_text(frontmatter + body.rstrip("\n") + "\n", encoding="utf-8")
|
|
136
|
+
note_rel = project_rel(path, project_root)
|
|
137
|
+
return {
|
|
138
|
+
"notePath": note_rel,
|
|
139
|
+
"taskKey": task_key,
|
|
140
|
+
"kind": kind,
|
|
141
|
+
"createdAt": created_at,
|
|
142
|
+
# 노트는 inert — 다음 run 이 자동으로 읽지 않는다. 이 인자를 그대로
|
|
143
|
+
# `okstra run ...` 에 붙여야만 instruction-set 에 주입된다.
|
|
144
|
+
"clarificationResponseArg": f"--clarification-response {note_rel}",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
94
148
|
def _add_common(sp) -> None:
|
|
95
149
|
# skill 이 쓰는 `<verb> <target> --project-root <root>` 형태를 받으려면
|
|
96
150
|
# 공통 옵션을 부모가 아닌 각 서브파서에 등록해야 한다.
|
|
@@ -115,12 +169,35 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
115
169
|
p_rec.add_argument("--answer", required=True)
|
|
116
170
|
p_rec.add_argument("--citation", action="append", default=[])
|
|
117
171
|
|
|
172
|
+
p_note = sub.add_parser(
|
|
173
|
+
"note", help="write an agent-authored note into <task-root>/notes/")
|
|
174
|
+
p_note.add_argument("target", help="task root path or task-key")
|
|
175
|
+
_add_common(p_note)
|
|
176
|
+
p_note.add_argument("--kind", choices=NOTE_KINDS, required=True)
|
|
177
|
+
p_note.add_argument("--slug", required=True,
|
|
178
|
+
help="short topic slug for the filename")
|
|
179
|
+
p_note.add_argument("--purpose", required=True,
|
|
180
|
+
help="one line — what it is and which run/decision it feeds")
|
|
181
|
+
p_note.add_argument("--scope-note", required=True, dest="scope_note",
|
|
182
|
+
help="one line — what it is NOT (e.g. not a user decision)")
|
|
183
|
+
body_src = p_note.add_mutually_exclusive_group(required=True)
|
|
184
|
+
body_src.add_argument("--body", help="inline markdown body")
|
|
185
|
+
body_src.add_argument("--body-file", dest="body_file",
|
|
186
|
+
help="path to a file holding the markdown body")
|
|
187
|
+
|
|
118
188
|
args = parser.parse_args(argv)
|
|
119
189
|
task_root, project_root = resolve_task_root(
|
|
120
190
|
args.target, args.project_root, args.cwd)
|
|
121
191
|
|
|
122
192
|
if args.command == "assemble":
|
|
123
193
|
result = assemble_recap(task_root, project_root)
|
|
194
|
+
elif args.command == "note":
|
|
195
|
+
body = (Path(args.body_file).read_text(encoding="utf-8")
|
|
196
|
+
if args.body_file else args.body)
|
|
197
|
+
result = write_note(
|
|
198
|
+
task_root, project_root, kind=args.kind, slug=args.slug,
|
|
199
|
+
purpose=args.purpose, scope_note=args.scope_note, body=body,
|
|
200
|
+
now=dt.datetime.now(dt.timezone.utc))
|
|
124
201
|
else:
|
|
125
202
|
result = append_recap_entry(
|
|
126
203
|
task_root, project_root, kind=args.kind, mode=args.mode,
|
|
@@ -108,6 +108,46 @@ class RunMeta:
|
|
|
108
108
|
source_report: str # relative path of the .md the HTML is derived from
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
# task-type itself can contain hyphens (``implementation-planning``,
|
|
112
|
+
# ``final-verification``, ``release-handoff``), so the filename segment
|
|
113
|
+
# between ``final-report-`` and ``-<seq>.md`` is matched greedily; the
|
|
114
|
+
# trailing ``-(?P<seq>\d+)\.md$`` anchor pins seq to the last numeric tail.
|
|
115
|
+
_REPORT_PATH_RE = re.compile(
|
|
116
|
+
r"runs/(?P<task_type>[^/]+)/reports/final-report-.+-(?P<seq>\d+)\.md$"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _infer_run_meta_from_path(path: Path) -> dict:
|
|
121
|
+
m = _REPORT_PATH_RE.search(path.as_posix())
|
|
122
|
+
return {"task_type": m.group("task_type"), "seq": m.group("seq")} if m else {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _infer_run_meta_from_body(text: str) -> dict:
|
|
126
|
+
found: dict[str, str] = {}
|
|
127
|
+
for label, key in (("Task Key", "task_key"), ("Task Type", "task_type")):
|
|
128
|
+
m = re.search(rf"^- {label}:\s*(\S.*?)\s*$", text, re.MULTILINE)
|
|
129
|
+
if m:
|
|
130
|
+
found[key] = m.group(1)
|
|
131
|
+
return found
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def infer_run_meta(report_path: Path, *, task_key: Optional[str] = None,
|
|
135
|
+
task_type: Optional[str] = None, seq: Optional[str] = None,
|
|
136
|
+
source_report: Optional[str] = None) -> RunMeta:
|
|
137
|
+
"""Derive a ``RunMeta`` from a final-report path/body, honouring any
|
|
138
|
+
explicit override. Single reference point for BOTH the HTML view render
|
|
139
|
+
script and the in-session user-response writer, so sidecar match keys
|
|
140
|
+
(task_type/seq/source-report) never drift between the two paths."""
|
|
141
|
+
text = report_path.read_text(encoding="utf-8")
|
|
142
|
+
inferred = {**_infer_run_meta_from_path(report_path), **_infer_run_meta_from_body(text)}
|
|
143
|
+
return RunMeta(
|
|
144
|
+
task_key=task_key or inferred.get("task_key") or "unknown",
|
|
145
|
+
task_type=task_type or inferred.get("task_type") or "unknown",
|
|
146
|
+
seq=seq or inferred.get("seq") or "000",
|
|
147
|
+
source_report=source_report or report_path.name,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
111
151
|
@dataclass(frozen=True)
|
|
112
152
|
class ReportViewModel:
|
|
113
153
|
run_meta: RunMeta
|
|
@@ -924,6 +964,7 @@ class UserResponseEntry:
|
|
|
924
964
|
kind: str
|
|
925
965
|
value: str
|
|
926
966
|
rationale: Optional[str] = None
|
|
967
|
+
disposition: str = "answer"
|
|
927
968
|
|
|
928
969
|
|
|
929
970
|
@dataclass(frozen=True)
|
|
@@ -961,12 +1002,11 @@ def serialize_user_response(
|
|
|
961
1002
|
has_approval = approval is not None and approval.approved
|
|
962
1003
|
body_chunks: list[str] = []
|
|
963
1004
|
for e in entries:
|
|
964
|
-
chunk =
|
|
965
|
-
|
|
966
|
-
f"-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
)
|
|
1005
|
+
chunk = f"\n## {e.response_id}\n- Kind: {e.kind}\n"
|
|
1006
|
+
if e.disposition != "answer":
|
|
1007
|
+
chunk += f"- Disposition: {e.disposition}\n"
|
|
1008
|
+
value_lines = e.value.strip().split("\n")
|
|
1009
|
+
chunk += "- Value:\n" + "".join(f" > {ln}\n" for ln in value_lines)
|
|
970
1010
|
if e.rationale:
|
|
971
1011
|
chunk += f"- Rationale: {e.rationale.strip()}\n"
|
|
972
1012
|
body_chunks.append(chunk)
|