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.
Files changed (34) hide show
  1. package/README.md +115 -104
  2. package/docs/architecture/storage-model.md +300 -0
  3. package/docs/architecture.md +802 -0
  4. package/docs/cli.md +658 -0
  5. package/docs/container.md +124 -0
  6. package/docs/contributor-change-matrix.md +5 -4
  7. package/docs/follow-ups/2026-07-10-final-report-option-3.md +51 -0
  8. package/docs/performance-improvement-plan-v2.md +374 -0
  9. package/docs/project-structure-overview.md +21 -10
  10. package/package.json +10 -5
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/bin/okstra-render-report-views.py +5 -35
  13. package/runtime/prompts/launch.template.md +1 -0
  14. package/runtime/python/okstra_ctl/clarification_items.py +48 -0
  15. package/runtime/python/okstra_ctl/recap.py +80 -3
  16. package/runtime/python/okstra_ctl/report_views.py +46 -6
  17. package/runtime/python/okstra_ctl/user_response.py +190 -0
  18. package/runtime/skills/okstra-inspect/SKILL.md +37 -2
  19. package/runtime/skills/okstra-pr-gen/SKILL.md +9 -8
  20. package/runtime/skills/okstra-user-response/SKILL.md +121 -0
  21. package/runtime/templates/reports/report.js +6 -3
  22. package/runtime/templates/reports/user-response.template.md +1 -0
  23. package/src/cli-registry.mjs +7 -0
  24. package/src/commands/inspect/recap.mjs +6 -1
  25. package/src/commands/inspect/user-response.mjs +26 -0
  26. package/src/lib/skill-catalog.mjs +1 -0
  27. package/README.kr.md +0 -231
  28. package/docs/kr/architecture/storage-model.md +0 -297
  29. package/docs/kr/architecture.md +0 -815
  30. package/docs/kr/cli.md +0 -657
  31. package/docs/kr/container.md +0 -122
  32. package/docs/kr/follow-ups/2026-07-10-final-report-option-3.md +0 -51
  33. package/docs/kr/performance-improvement-plan-v2.md +0 -374
  34. package/docs/kr/performance-improvement-plan.md +0 -147
@@ -1,6 +1,6 @@
1
1
  # Okstra — 폴더 구조 및 기능 요약
2
2
 
3
- > 현재 저장소 기준의 living map입니다. 빠른 사용법은 [`README.kr.md`](../README.kr.md), 내부 실행 계약은 [`docs/kr/architecture.md`](kr/architecture.md), CLI 세부 옵션은 [`docs/kr/cli.md`](kr/cli.md)를 함께 보세요.
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 (10)
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 / README.kr.md user manuals
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
- 9 user-facing skills (the only skills `okstra install` copies):
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*`, this file, and `docs/kr/cli.md`.
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*`, this file, install/uninstall fallback lists, and `CHANGES.md`.
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/kr/architecture.md`, `docs/kr/cli.md`, and `README*` if user-facing.
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.117.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/kr/",
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
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.117.0",
3
- "builtAt": "2026-07-14T11:54:12.876Z",
2
+ "package": "0.119.0",
3
+ "builtAt": "2026-07-15T11:10:05.274Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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 RunMeta, render_html_view # noqa: E402
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
- inferred = {**_infer_from_path(report_path), **_infer_from_body(report_path.read_text(encoding="utf-8"))}
123
- task_key = args.task_key or inferred.get("task_key") or "unknown"
124
- task_type = args.task_type or inferred.get("task_type") or "unknown"
125
- seq = args.seq or inferred.get("seq") or "000"
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 and
2
- append a recap Q&A log. Mutates only <task-root>/recap/recap-log.jsonl;
3
- never touches task-manifest / catalog / timeline.
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
- f"\n## {e.response_id}\n"
966
- f"- Kind: {e.kind}\n"
967
- "- Value:\n"
968
- f" > {e.value.strip()}\n"
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)