okstra 0.225.0 → 0.226.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/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -0
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/pr/pr.mjs +42 -13
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/docs/cli.md +19 -3
- package/docs/project-structure-overview.md +2 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/code-reviewer.json +7 -4
- package/runtime/prompts/duties/technical-verification-worker.json +2 -1
- package/runtime/prompts/lead/convergence.md +4 -1
- package/runtime/prompts/lead/phase-routing.md +7 -3
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +15 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +5 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +108 -2
- package/runtime/python/okstra_ctl/approved_plan_stage_extract.py +206 -0
- package/runtime/python/okstra_ctl/brief_sources.py +200 -0
- package/runtime/python/okstra_ctl/business_flow/store.py +15 -2
- package/runtime/python/okstra_ctl/cmux.py +22 -5
- package/runtime/python/okstra_ctl/code_review_coverage.py +26 -2
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -1
- package/runtime/python/okstra_ctl/design_surfaces.py +19 -7
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +20 -18
- package/runtime/python/okstra_ctl/implementation_direction.py +10 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +41 -0
- package/runtime/python/okstra_ctl/manager_split.py +185 -27
- package/runtime/python/okstra_ctl/next_phase.py +3 -1
- package/runtime/python/okstra_ctl/phases/final_verification/prior_blockers.py +128 -0
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +8 -5
- package/runtime/python/okstra_ctl/phases/final_verification/report_assets/final-verification.template.html +1 -1
- package/runtime/python/okstra_ctl/phases/final_verification/validation.py +84 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +13 -5
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +67 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +4 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/report_assets/implementation-option-selection.template.html +2 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +127 -18
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +2 -2
- package/runtime/python/okstra_ctl/report_assembly.py +23 -2
- package/runtime/python/okstra_ctl/report_views.py +2 -1
- package/runtime/python/okstra_ctl/run.py +46 -1
- package/runtime/python/okstra_ctl/run_context.py +13 -8
- package/runtime/python/okstra_ctl/wizard/confirmation.py +6 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +6 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +21 -0
- package/runtime/python/okstra_ctl/wizard/render.py +4 -0
- package/runtime/python/okstra_ctl/wizard/sources.py +33 -11
- package/runtime/python/okstra_ctl/wizard/state.py +4 -0
- package/runtime/python/okstra_ctl/wizard/steps_options.py +74 -9
- package/runtime/python/okstra_ctl/worker_prompt_body.py +21 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +11 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/schemas/final-report-v2.0.schema.json +74 -0
- package/runtime/schemas/final-report-v3.0.schema.json +99 -2
- package/runtime/skills/okstra-brief-gen/SKILL.md +26 -0
- package/runtime/skills/okstra-code-review/SKILL.md +2 -0
- package/runtime/skills/okstra-code-review/references/census-rules.md +16 -2
- package/runtime/skills/okstra-code-review/references/review-calibration.md +7 -1
- package/runtime/skills/okstra-manager/SKILL.md +15 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +15 -1
- package/runtime/templates/reports/brief.template.md +10 -1
- package/runtime/templates/reports/html/i18n/en.json +8 -0
- package/runtime/templates/reports/html/i18n/ko.json +8 -0
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-brief.py +39 -1
- package/runtime/validators/validate-run.py +5 -0
|
@@ -15,7 +15,7 @@ Allowed targets:
|
|
|
15
15
|
- `implementation`
|
|
16
16
|
- `done`
|
|
17
17
|
|
|
18
|
-
`finalVerification.routingRecommendation` is an **object** with exactly two fields — `target`, one value from the Allowed targets list, and `rationale`, the sentence tying that choice to the verdict and the blocker list. Free routing prose is not the field; a target named only in the prose does not route the task, because Phase 7 projects `workflow.nextRecommendedPhase` from `target` alone. `final-verification` re-runs this phase on the same head and is for exactly one situation: every remaining blocker is an environment or configuration fault whose cause this report already names — a `qaCommands` entry pointing at a path that no longer exists, a missing credential, a stale fixture — so nothing in the code, the plan, or the selected direction is being re-decided. Name the repair in the `rationale`. When any blocker needs a code, plan, or direction change, route to the phase that owns that change instead; routing a defect you have not diagnosed back into this phase re-runs the verification that already failed. Both `release-handoff` forms are allowed ONLY when the verdict is release-ready — `accepted`, or `conditional-accept` with every condition declaring `blocksReleaseHandoff: false`. Either verification scope may route there: release-handoff opens one PR per stage, so a release-ready `single-stage` run is the evidence for that stage's PR, and `release-handoff(stage-group)` is only a scope qualifier that projects onto the same phase. `done` ends the lifecycle here. Enforcement: `schemas/final-report-v2.0.schema.json` rejects a `target` outside the enum, a missing `rationale`, and a string in place of the object; `validators/validate-run.py` rejects a missing `target` and a verdict that is not release-ready routed to either `release-handoff` form (naming the condition ids that block it).
|
|
18
|
+
`finalVerification.routingRecommendation` is an **object** with exactly two fields — `target`, one value from the Allowed targets list, and `rationale`, the sentence tying that choice to the verdict and the blocker list. Free routing prose is not the field; a target named only in the prose does not route the task, because Phase 7 projects `workflow.nextRecommendedPhase` from `target` alone. `final-verification` re-runs this phase on the same head and is for exactly one situation: every remaining blocker is an environment or configuration fault whose cause this report already names — a `qaCommands` entry pointing at a path that no longer exists, a missing credential, a stale fixture — so nothing in the code, the plan, or the selected direction is being re-decided. Name the repair in the `rationale`. When any blocker needs a code, plan, or direction change, route to the phase that owns that change instead; routing a defect you have not diagnosed back into this phase re-runs the verification that already failed. Both `release-handoff` forms are allowed ONLY when the verdict is release-ready — `accepted`, or `conditional-accept` with every condition declaring `blocksReleaseHandoff: false`. Either verification scope may route there: release-handoff opens one PR per stage, so a release-ready `single-stage` run is the evidence for that stage's PR, and `release-handoff(stage-group)` is only a scope qualifier that projects onto the same phase. `done` ends the lifecycle here. A report carrying a blocker whose `defectClass.recurrence` is `recurring` — an earlier final-verification run of the same target already blocked on that mechanism — routes to `error-analysis`, `implementation-option-selection`, or `implementation-planning` so the whole class is re-planned; `implementation` and `final-verification` are refused there. Enforcement: `schemas/final-report-v2.0.schema.json` rejects a `target` outside the enum, a missing `rationale`, and a string in place of the object; `validators/validate-run.py` rejects a missing `target` and a verdict that is not release-ready routed to either `release-handoff` form (naming the condition ids that block it); `phases/final_verification/validation.py` `_validate_recurring_class_routing` rejects the recurring-class case.
|
|
19
19
|
|
|
20
20
|
## release-handoff
|
|
21
21
|
|
|
@@ -35,11 +35,15 @@ Enforcement: `technicalVerification.routing.nextTaskType` and phase-continuation
|
|
|
35
35
|
|
|
36
36
|
## implementation-option-selection
|
|
37
37
|
|
|
38
|
-
When no candidate is valid and explicit eligible technical facts remain, route to `technical-verification` to collect experimental evidence. Keep `rankedOptions` empty and `recommendedOptionId` null. `validate_blocked_answer_channel` rejects this route while user decisions remain unresolved or no safe, explicitly classified technical fact is available. Historical blocked reports can be supplied explicitly without rewriting their verdict.
|
|
38
|
+
When no candidate is valid and explicit eligible technical facts remain, route to `technical-verification` to collect experimental evidence. A fact that only a deployed environment or another later gate can close is `deferred-gate`, not `technical-verification`: technical-verification may not deploy, so routing such a fact there returns the same fact. A `deferred-gate` fact that names its `gate` does not make the candidate invalid. Keep `rankedOptions` empty and `recommendedOptionId` null. `validate_blocked_answer_channel` rejects this route while user decisions remain unresolved or no safe, explicitly classified technical fact is available. Historical blocked reports can be supplied explicitly without rewriting their verdict.
|
|
39
39
|
|
|
40
40
|
With valid options set routing to `pending-direction-selection`; without a valid option use `blocked`.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
A candidate that fails only on a split vote (at least one `feasible`, no safety blocker) does not route back to `technical-verification` or to another option-selection run on that ground: the same analysers return the same split. Open the approval decision the profile describes; the user's `accept-risk` answer, cited in the candidate's `splitAcceptance`, makes it valid and the run routes as a valid run.
|
|
43
|
+
|
|
44
|
+
The run's mode is the one prepare recorded in the analysis packet's `## Direction Selection Mode` section (`--preselected-direction`); report that mode.
|
|
45
|
+
|
|
46
|
+
In `candidate-comparison`, keep `preselectedDirection` null and do not route directly to `implementation-planning`. In `preselected-validation`, keep a cited `preselectedDirection` whose `optionId` names the one direction. When it validates, emit it as the only ranked option with an empty `candidateAudit` and routing `implementation-planning`. When it does not validate, leave `rankedOptions` empty, record it as the only `candidateAudit` row with its rejection reason, keep `recommendedOptionId` null, and route `technical-verification` or `blocked` by the rules above. Do not rewrite the record into `candidate-comparison`.
|
|
43
47
|
|
|
44
48
|
## requirements-discovery
|
|
45
49
|
|
|
@@ -176,7 +176,7 @@ After each worker attempt returns (regardless of role), Lead MUST verify the can
|
|
|
176
176
|
- The result file exists but its audit sidecar does not, at `runs/<task-type>/worker-results/<worker>-audit-<task-type>-<seq>.md`. Workers write both in the same step, so a result without a sidecar means the Reading Confirmation block — the only evidence the worker read its inputs — was never produced. `validate-run.py` fails the run on this at Phase 7 either way (`validate_worker_results_audit`); checking it here spends the existing one-retry budget while the role can still be re-dispatched, instead of surfacing hours later when the worker session is gone.
|
|
177
177
|
- `okstra worker-audit-check --run-dir <runDirectoryPath from the active run context> --task-type <t> --seq <n> --worker <id>` exits 2 on a backticked `path:line` citation in the worker's result that has no matching Evidence read row in its audit sidecar. Run it the moment you collect each result. Phase 7 enforces the same rules from the same implementation (`okstra_ctl.worker_audit_ledger`), but by then the worker session is gone and the only remaining moves are editing the result yourself — which destroys the audit chain the ledger exists to provide — or ending the run `contract-violated`. While the session is alive, `SendMessage` to the worker so it corrects its own citation; that costs about a minute against a re-dispatch or a failed run.
|
|
178
178
|
|
|
179
|
-
The same audit check parses command evidence from canonical rows such as `- Evidence command: {"command":"npm run check","cwd":"<
|
|
179
|
+
The same audit check parses command evidence from canonical rows such as `- Evidence command: {"command":"npm run check","cwd":"<directory the command ran in>","exitCode":0,"outputSummary":"all checks passed"}`. Workers record only commands that produced or verified a conclusion, not exploratory `rg`, `ls`, or file-opening commands. A malformed row is a contract failure returned by `okstra_ctl.worker_audit_ledger`; send the failure to the worker while its session is still available. Nothing scans these rows for secrets, so a row is never refused for how a setting is named — `AUTH_MODE=off` and `TOKEN_TTL=60` record verbatim. Do not tell a worker to drop or paraphrase a row on those grounds: a worker that cannot record the command it ran has no way to satisfy the evidence ledger that demands it.
|
|
180
180
|
|
|
181
181
|
**One-retry policy:**
|
|
182
182
|
|
|
@@ -526,6 +526,21 @@
|
|
|
526
526
|
"label": "지난 run 의 확인 질문에 답한 파일 경로 (이어서 도는 run 에서만, 없으면 빈 줄)",
|
|
527
527
|
"echo_template": "clarification: {value}"
|
|
528
528
|
},
|
|
529
|
+
"selection_mode_pick": {
|
|
530
|
+
"label": "이번 방향 선택 run 의 모드를 고르세요. 설계 워커는 이 모드를 분석 패킷의 `## Direction Selection Mode` 절에서 읽습니다. 이미 정해진 방향 하나를 검증만 할 run 이면 preselected-validation 을 고르고 그 방향과 근거를 적으세요.",
|
|
531
|
+
"options": {
|
|
532
|
+
"candidate-comparison": "candidate-comparison — 후보를 최대 3개 제안하고 비교",
|
|
533
|
+
"preselected-validation": "preselected-validation — 정해진 방향 하나만 검증 (방향과 근거 직접 입력)"
|
|
534
|
+
},
|
|
535
|
+
"labels": {
|
|
536
|
+
"upstream": " 이어받는 리포트의 다음 단계 지시: {steps}"
|
|
537
|
+
},
|
|
538
|
+
"echo_template": "selection-mode: {value}"
|
|
539
|
+
},
|
|
540
|
+
"preselected_direction": {
|
|
541
|
+
"label": "검증할 방향과 그 방향을 정한 근거(명세 리비전, 브리프 절, 이전 리포트 등)를 한 문단으로 적으세요",
|
|
542
|
+
"echo_template": "preselected-direction: {value}"
|
|
543
|
+
},
|
|
529
544
|
"pr_template": {
|
|
530
545
|
"label": "PR 본문 템플릿 경로 1회성 override (빈 줄이면 project.json → ~/.okstra/config.json → 스킬 디폴트 순으로 자동 해석)",
|
|
531
546
|
"echo_template": "pr-template: {value}"
|
|
@@ -113,6 +113,11 @@ class CodexExecution:
|
|
|
113
113
|
elif spec is not None:
|
|
114
114
|
argv += spec.cli_args
|
|
115
115
|
argv += ["--sandbox", "danger-full-access"]
|
|
116
|
+
# The available-skills catalog advertises host skills (coding-preflight,
|
|
117
|
+
# graphify) that a worker's Read scope forbids, and codex workers read
|
|
118
|
+
# them as host instructions. `$CODEX_HOME/AGENTS.md` cannot be turned off
|
|
119
|
+
# per invocation, so the Read scope line still has to cover it.
|
|
120
|
+
argv += ["-c", "skills.include_instructions=false"]
|
|
116
121
|
if request.policy.auto_approve:
|
|
117
122
|
argv += ["-c", "approval_policy=never"]
|
|
118
123
|
argv.append("-")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Build the compact analysis-worker input packet for a task run."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import hashlib
|
|
4
5
|
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -94,6 +95,10 @@ CLARIFICATION_SECTIONS = (
|
|
|
94
95
|
"Clarification Response Carried In From Previous Run",
|
|
95
96
|
)
|
|
96
97
|
_FRONTMATTER_RE = re.compile(r"\A---\n(?P<body>.*?)\n---\n", re.DOTALL)
|
|
98
|
+
BY_REFERENCE_FORMAT = "by-reference"
|
|
99
|
+
MAX_CITED_DOCUMENT_BYTES = 200_000
|
|
100
|
+
_FILE_SOURCE_RE = re.compile(r"\A### Source \d+\s+—\s+file\s*\Z")
|
|
101
|
+
_SOURCE_FIELD_RE = re.compile(r"\A- (ref|format):\s*(.*?)\s*\Z")
|
|
97
102
|
|
|
98
103
|
|
|
99
104
|
def build_analysis_packet(
|
|
@@ -118,6 +123,9 @@ def build_analysis_packet(
|
|
|
118
123
|
mcp_servers_text: str = "",
|
|
119
124
|
technical_evidence_text: str = "",
|
|
120
125
|
verification_target_path: str = "",
|
|
126
|
+
selection_mode_text: str = "",
|
|
127
|
+
cited_documents_text: str = "",
|
|
128
|
+
prior_verification_text: str = "",
|
|
121
129
|
) -> str:
|
|
122
130
|
"""Return the primary compact input for Claude/Codex/Antigravity analysers.
|
|
123
131
|
|
|
@@ -141,8 +149,17 @@ def build_analysis_packet(
|
|
|
141
149
|
`technical_evidence_text` 는 carry-in 이 technical-verification 결과일 때
|
|
142
150
|
`technical_evidence_packet_block` 이 렌더한 증거 위치 절이다.
|
|
143
151
|
|
|
152
|
+
`selection_mode_text` 는 implementation-option-selection 의
|
|
153
|
+
`selection_mode_packet_block` 이 렌더한 선택 모드 절이다.
|
|
154
|
+
|
|
155
|
+
`cited_documents_text` 는 `cited_documents_block` 이 렌더한 인용 로컬 문서
|
|
156
|
+
절이다. 빈 문자열이면 브리프가 참조로 인용한 파일이 없다.
|
|
157
|
+
|
|
144
158
|
`verification_target_path` 는 final-verification prepare 가 run 별로 기록한
|
|
145
159
|
검증 대상 스냅샷 경로(매니페스트 `verificationTargetPath` 와 같은 값)다.
|
|
160
|
+
|
|
161
|
+
`prior_verification_text` 는 같은 검증 대상의 이전 final-verification run 이
|
|
162
|
+
남긴 차단 사유를 `prior_blockers_packet_block` 이 렌더한 절이다.
|
|
146
163
|
"""
|
|
147
164
|
brief_text = task_brief_path.read_text(encoding="utf-8")
|
|
148
165
|
group_context_text = _read_optional(group_context_path) if group_context_path else ""
|
|
@@ -163,9 +180,15 @@ def build_analysis_packet(
|
|
|
163
180
|
has_selected_direction,
|
|
164
181
|
)
|
|
165
182
|
)
|
|
183
|
+
# The directive overrides the brief and the plan text below it, so it comes
|
|
184
|
+
# before them. As the last section of a 4,865-line packet (dev-11562), an
|
|
185
|
+
# executor stopped on a plan/brief conflict the directive had resolved.
|
|
186
|
+
parts.extend(_directive_block(directive))
|
|
166
187
|
# 짧은 포인터 절이라 46KB 에서 잘리는 읽기에서도 도착하도록 브리프보다 앞에 둔다.
|
|
167
188
|
if technical_evidence_text:
|
|
168
189
|
parts.extend(["", technical_evidence_text])
|
|
190
|
+
if selection_mode_text:
|
|
191
|
+
parts.extend(["", selection_mode_text])
|
|
169
192
|
# 그룹 문서는 사람 절과 okstra 메모리 영역으로 갈라 싣는다. 사람 절("왜")은
|
|
170
193
|
# 브리프 발췌보다 앞 — 짧고, 46KB 에서 잘리는 읽기(agy view_file)에서도
|
|
171
194
|
# 도착해야 한다. 형제 task 의 결론은 브리프 뒤 — 브리프를 밀어내면 안 된다.
|
|
@@ -174,6 +197,8 @@ def build_analysis_packet(
|
|
|
174
197
|
)
|
|
175
198
|
parts.extend(_group_context_block(group_human))
|
|
176
199
|
parts.extend(_brief_block(brief_text))
|
|
200
|
+
if cited_documents_text:
|
|
201
|
+
parts.extend(["", cited_documents_text])
|
|
177
202
|
if mcp_servers_text:
|
|
178
203
|
parts.extend(["", "## Available MCP Servers", "", mcp_servers_text])
|
|
179
204
|
if direct_work_text:
|
|
@@ -193,8 +218,9 @@ def build_analysis_packet(
|
|
|
193
218
|
parts.extend(_stage_ledger_block(stage_ledger_json))
|
|
194
219
|
parts.extend(_stage_ledger_unavailable_block(stage_ledger_notice))
|
|
195
220
|
parts.extend(_prior_planning_block(prior_planning_summary))
|
|
221
|
+
if prior_verification_text:
|
|
222
|
+
parts.extend(["", prior_verification_text, ""])
|
|
196
223
|
parts.extend(_clarification_block(clarification_text))
|
|
197
|
-
parts.extend(_directive_block(directive))
|
|
198
224
|
body = "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
|
|
199
225
|
return _with_section_index(body)
|
|
200
226
|
|
|
@@ -290,7 +316,8 @@ def _with_section_index(body: str) -> str:
|
|
|
290
316
|
f"This packet is {total} lines. Read a section by line range with"
|
|
291
317
|
" `sed -n '<start>,<end>p'` rather than opening the whole file: a"
|
|
292
318
|
" whole-file read is truncated by some worker CLIs without reporting"
|
|
293
|
-
" it, and the sections that describe current state are last."
|
|
319
|
+
" it, and the sections that describe current state are last. Page a"
|
|
320
|
+
" range longer than 200 lines in 200-line reads.",
|
|
294
321
|
"",
|
|
295
322
|
*rows,
|
|
296
323
|
]
|
|
@@ -489,6 +516,85 @@ def _group_memory_block(
|
|
|
489
516
|
return lines
|
|
490
517
|
|
|
491
518
|
|
|
519
|
+
|
|
520
|
+
def cited_documents_block(brief_text: str, project_root: Path) -> str:
|
|
521
|
+
"""브리프 Source Material 이 참조로 인용한 로컬 파일을 패킷 절로 싣는다.
|
|
522
|
+
|
|
523
|
+
`- format: by-reference` 인 `### Source N — file` 항목은 본문 대신 파일을
|
|
524
|
+
가리킨다. task worktree 는 체크아웃이라 프로젝트 루트의 untracked 파일
|
|
525
|
+
(`.project-docs/` 스펙)이 없고, 설계자들은 그 스펙이 이미 정한 점에
|
|
526
|
+
`uncertain` 을 던졌다(fontradar dev-11562). prepare 는 Source Material 이
|
|
527
|
+
인용한 경로만 프로젝트 루트에서 읽기 전용으로 읽고, 루트 밖 경로는 읽지
|
|
528
|
+
않는다(artifact-home 규칙).
|
|
529
|
+
"""
|
|
530
|
+
root = project_root.resolve()
|
|
531
|
+
entries: list[str] = []
|
|
532
|
+
for ref in _by_reference_file_refs(_section_map(brief_text).get("Source Material", "")):
|
|
533
|
+
candidate = Path(ref).expanduser()
|
|
534
|
+
path = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
535
|
+
try:
|
|
536
|
+
relative = path.relative_to(root).as_posix()
|
|
537
|
+
except ValueError:
|
|
538
|
+
entries.append(f"### `{ref}`\n\n- not read: outside the project root")
|
|
539
|
+
continue
|
|
540
|
+
if not path.is_file():
|
|
541
|
+
entries.append(f"### `{relative}`\n\n- not read: no such file under the project root")
|
|
542
|
+
continue
|
|
543
|
+
content = path.read_bytes()
|
|
544
|
+
if len(content) > MAX_CITED_DOCUMENT_BYTES:
|
|
545
|
+
entries.append(
|
|
546
|
+
f"### `{relative}`\n\n- not read: {len(content)} bytes exceeds "
|
|
547
|
+
f"{MAX_CITED_DOCUMENT_BYTES}; paste the cited part into Source Material"
|
|
548
|
+
)
|
|
549
|
+
continue
|
|
550
|
+
text = content.decode("utf-8", errors="replace").rstrip("\n")
|
|
551
|
+
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
|
|
552
|
+
fence = "`" * max(3, longest + 1)
|
|
553
|
+
entries.append(
|
|
554
|
+
f"### `{relative}`\n\n"
|
|
555
|
+
f"- sha256: {hashlib.sha256(content).hexdigest()}\n\n"
|
|
556
|
+
f"{fence}\n{text}\n{fence}"
|
|
557
|
+
)
|
|
558
|
+
if not entries:
|
|
559
|
+
return ""
|
|
560
|
+
return "\n\n".join([
|
|
561
|
+
"## Cited Local Documents",
|
|
562
|
+
"Read-only copies of the files the brief's Source Material cites by "
|
|
563
|
+
"reference, read from the project root when this run was prepared. The "
|
|
564
|
+
"task worktree may not contain them. Treat each as Source Material and "
|
|
565
|
+
"cite it as `<path>:<line>`, with the line numbers of the copy below.",
|
|
566
|
+
*entries,
|
|
567
|
+
])
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _by_reference_file_refs(source_material: str) -> list[str]:
|
|
571
|
+
refs = [ref for ref, fmt in file_source_entries(source_material) if fmt == BY_REFERENCE_FORMAT]
|
|
572
|
+
return list(dict.fromkeys(refs))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def file_source_entries(source_material: str) -> list[tuple[str, str]]:
|
|
576
|
+
"""Source Material 의 `### Source N — file` 항목마다 (ref, format) 을 돌려준다."""
|
|
577
|
+
entries: list[tuple[str, str]] = []
|
|
578
|
+
lines = source_material.splitlines()
|
|
579
|
+
blocks: list[list[str]] = []
|
|
580
|
+
for line, fenced in zip(lines, _fence_mask(lines)):
|
|
581
|
+
if fenced:
|
|
582
|
+
continue
|
|
583
|
+
if line.startswith("### "):
|
|
584
|
+
blocks.append([line])
|
|
585
|
+
elif blocks:
|
|
586
|
+
blocks[-1].append(line)
|
|
587
|
+
for block in blocks:
|
|
588
|
+
if not _FILE_SOURCE_RE.match(block[0]):
|
|
589
|
+
continue
|
|
590
|
+
fields = dict(
|
|
591
|
+
match.groups() for line in block[1:]
|
|
592
|
+
if (match := _SOURCE_FIELD_RE.match(line))
|
|
593
|
+
)
|
|
594
|
+
if fields.get("ref", "").strip():
|
|
595
|
+
entries.append((fields["ref"].strip(), fields.get("format", "").strip()))
|
|
596
|
+
return entries
|
|
597
|
+
|
|
492
598
|
def _brief_block(brief_text: str) -> list[str]:
|
|
493
599
|
return [
|
|
494
600
|
"",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Project an approved implementation-planning report onto one stage.
|
|
2
|
+
|
|
3
|
+
An implementation executor owns exactly one stage, but its prompt pointed it at
|
|
4
|
+
the whole plan record. Measured 2026-10-30 (fontradar-v2-api dev-11562 stage 1):
|
|
5
|
+
that record was 7,465 lines / 385 KB, 94 KB of it the planning phase's own
|
|
6
|
+
verification ledger, and a codex executor reported `contract-violated` because
|
|
7
|
+
its reads overran the CLI's tool-output limit before it made an edit.
|
|
8
|
+
|
|
9
|
+
The extract keeps everything the stage can act on and drops only what is named
|
|
10
|
+
in `OMITTED`, each with the reason, so a reader can tell an omission from a gap.
|
|
11
|
+
Rows of a row family keep the stage's rows plus every row that names no stage
|
|
12
|
+
(`plan_items._stage_scope` reads that as "applies to every stage"), and every
|
|
13
|
+
row carries the `P-*` id the plan-body verification and lead directives cite it
|
|
14
|
+
by. The full record stays the source of record behind `sourcePlanPath`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Mapping
|
|
22
|
+
|
|
23
|
+
from .json_boundary import load_owned_object, write_owned_object_atomic
|
|
24
|
+
from .plan_items import PLAN_ITEM_SOURCES
|
|
25
|
+
|
|
26
|
+
EXTRACT_KIND = "approved-plan-stage-extract"
|
|
27
|
+
EXTRACT_SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
# implementationPlanning row families filtered to the stage. Every other
|
|
30
|
+
# implementationPlanning key is carried whole unless OMITTED names it.
|
|
31
|
+
_STAGE_ROW_FAMILIES = (
|
|
32
|
+
"requirementCoverage",
|
|
33
|
+
"validationChecklist",
|
|
34
|
+
"rollbackStrategy",
|
|
35
|
+
"dependencyMigrationRisk",
|
|
36
|
+
"crossProjectDependencies",
|
|
37
|
+
)
|
|
38
|
+
# plan_items numbers a row family by position (`P-Val-4` is the fourth
|
|
39
|
+
# validation row); a filtered list loses the position, so the id travels on the row.
|
|
40
|
+
_PLAN_ITEM_PREFIX = {
|
|
41
|
+
family: prefix for family, prefix, _ in PLAN_ITEM_SOURCES
|
|
42
|
+
if family in _STAGE_ROW_FAMILIES
|
|
43
|
+
}
|
|
44
|
+
# Top-level report keys an executor acts on. Everything else at the top level is
|
|
45
|
+
# report presentation, audit, or accounting and is listed in OMITTED.
|
|
46
|
+
_CARRIED_REPORT_KEYS = (
|
|
47
|
+
"clarificationItems",
|
|
48
|
+
"clarificationCarryIn",
|
|
49
|
+
"endStateCoverage",
|
|
50
|
+
"missingInformation",
|
|
51
|
+
"followUpTasks",
|
|
52
|
+
)
|
|
53
|
+
OMITTED = {
|
|
54
|
+
"implementationPlanning.planBodyVerification": (
|
|
55
|
+
"the planning phase's verification ledger (verdicts and dissent per plan "
|
|
56
|
+
"item); the plan body above is its settled result"
|
|
57
|
+
),
|
|
58
|
+
"implementationPlanning.designPreparation": (
|
|
59
|
+
"the stage-scoped effective decision is inlined in the executor prompt "
|
|
60
|
+
"under Effective design preparation"
|
|
61
|
+
),
|
|
62
|
+
"implementationPlanning.userNarrative": "plain-language explanation for the human reader",
|
|
63
|
+
"implementationPlanning.stages[other]": "stages this run does not own; their Stage Map rows stay",
|
|
64
|
+
"implementationPlanning.directionRealization.fileStructure[other]": (
|
|
65
|
+
"file rows whose path this stage's entry never names"
|
|
66
|
+
),
|
|
67
|
+
"<rows of another stage>": (
|
|
68
|
+
"requirementCoverage / validationChecklist / rollbackStrategy / "
|
|
69
|
+
"dependencyMigrationRisk / crossProjectDependencies rows whose stageRefs "
|
|
70
|
+
"exclude this stage and whose id this stage's entry never cites"
|
|
71
|
+
),
|
|
72
|
+
"humanSummary, verdictCard, rationale, summary, finalVerdict, recommendedNextSteps": (
|
|
73
|
+
"report presentation for the approver"
|
|
74
|
+
),
|
|
75
|
+
"crossVerification, agentActivity, evidence, executionStatus, executionRoles, tokenUsage": (
|
|
76
|
+
"planning-run audit and accounting"
|
|
77
|
+
),
|
|
78
|
+
"meta, frontmatter, header": "report identity",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class StageExtractError(ValueError):
|
|
83
|
+
"""The approved plan cannot be projected onto the requested stage."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def stage_extract_payload(
|
|
87
|
+
report: Mapping[str, Any],
|
|
88
|
+
stage_number: int,
|
|
89
|
+
source_plan_path: str,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
planning = report.get("implementationPlanning")
|
|
92
|
+
if not isinstance(planning, Mapping):
|
|
93
|
+
raise StageExtractError("approved plan has no implementationPlanning object")
|
|
94
|
+
stage_entry = _stage_entry(planning, stage_number)
|
|
95
|
+
cited_text = json.dumps(stage_entry, ensure_ascii=False)
|
|
96
|
+
carried_planning: dict[str, Any] = {}
|
|
97
|
+
for key, value in planning.items():
|
|
98
|
+
if f"implementationPlanning.{key}" in OMITTED:
|
|
99
|
+
continue
|
|
100
|
+
if key == "stages":
|
|
101
|
+
carried_planning[key] = [stage_entry]
|
|
102
|
+
elif key in _STAGE_ROW_FAMILIES:
|
|
103
|
+
carried_planning[key] = _stage_rows(value, key, stage_number, cited_text)
|
|
104
|
+
elif key == "directionRealization" and isinstance(value, Mapping):
|
|
105
|
+
carried_planning[key] = _direction_realization(value, cited_text)
|
|
106
|
+
else:
|
|
107
|
+
carried_planning[key] = value
|
|
108
|
+
payload: dict[str, Any] = {
|
|
109
|
+
"kind": EXTRACT_KIND,
|
|
110
|
+
"schemaVersion": EXTRACT_SCHEMA_VERSION,
|
|
111
|
+
"sourcePlanPath": source_plan_path,
|
|
112
|
+
"stage": stage_number,
|
|
113
|
+
"planItemIds": (
|
|
114
|
+
"Rows carry their plan item id as `planItemId`. `P-Step-<stage>.<step>` "
|
|
115
|
+
"names step <step> of that stage's `stepwiseExecution`."
|
|
116
|
+
),
|
|
117
|
+
"omitted": dict(OMITTED),
|
|
118
|
+
"implementationPlanning": carried_planning,
|
|
119
|
+
}
|
|
120
|
+
for key in _CARRIED_REPORT_KEYS:
|
|
121
|
+
if key in report:
|
|
122
|
+
payload[key] = report[key]
|
|
123
|
+
return payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def materialize_stage_extract(
|
|
127
|
+
plan_path: Path,
|
|
128
|
+
stage_number: int,
|
|
129
|
+
output_path: Path,
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""Write the extract for a `.data.json` plan; False when the plan is not one.
|
|
132
|
+
|
|
133
|
+
A Markdown plan predates the report record and has no structure to project,
|
|
134
|
+
and a missing file is reported by the prompt's own plan anchor, so neither
|
|
135
|
+
is an error here — the executor reads the plan as it did before.
|
|
136
|
+
"""
|
|
137
|
+
if not plan_path.name.endswith(".data.json") or not plan_path.is_file():
|
|
138
|
+
return False
|
|
139
|
+
report = load_owned_object(plan_path, artifact="approved implementation plan")
|
|
140
|
+
payload = stage_extract_payload(report, stage_number, str(plan_path))
|
|
141
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
write_owned_object_atomic(output_path, payload, artifact="approved plan stage extract")
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _stage_entry(planning: Mapping[str, Any], stage_number: int) -> Mapping[str, Any]:
|
|
147
|
+
stages = planning.get("stages")
|
|
148
|
+
if not isinstance(stages, list):
|
|
149
|
+
raise StageExtractError("approved plan has no stages array")
|
|
150
|
+
for entry in stages:
|
|
151
|
+
if isinstance(entry, Mapping) and entry.get("stage") == stage_number:
|
|
152
|
+
return entry
|
|
153
|
+
raise StageExtractError(f"approved plan has no stage {stage_number}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _stage_rows(
|
|
157
|
+
rows: Any,
|
|
158
|
+
family: str,
|
|
159
|
+
stage_number: int,
|
|
160
|
+
cited_text: str,
|
|
161
|
+
) -> list[Any]:
|
|
162
|
+
if not isinstance(rows, list):
|
|
163
|
+
return rows
|
|
164
|
+
kept = []
|
|
165
|
+
for position, row in enumerate(rows, start=1):
|
|
166
|
+
if not isinstance(row, Mapping):
|
|
167
|
+
kept.append(row)
|
|
168
|
+
continue
|
|
169
|
+
if not _row_applies(row, stage_number, cited_text):
|
|
170
|
+
continue
|
|
171
|
+
prefix = _PLAN_ITEM_PREFIX.get(family)
|
|
172
|
+
kept.append(
|
|
173
|
+
{"planItemId": f"{prefix}-{position}", **row} if prefix else dict(row)
|
|
174
|
+
)
|
|
175
|
+
return kept
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _row_applies(row: Mapping[str, Any], stage_number: int, cited_text: str) -> bool:
|
|
179
|
+
refs = row.get("stageRefs")
|
|
180
|
+
stages = (
|
|
181
|
+
[value for value in refs if isinstance(value, int) and not isinstance(value, bool)]
|
|
182
|
+
if isinstance(refs, list) else []
|
|
183
|
+
)
|
|
184
|
+
if not stages or stage_number in stages:
|
|
185
|
+
return True
|
|
186
|
+
row_id = row.get("id")
|
|
187
|
+
return isinstance(row_id, str) and bool(row_id) and _cites(cited_text, row_id)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _direction_realization(
|
|
191
|
+
realization: Mapping[str, Any], cited_text: str
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
carried = dict(realization)
|
|
194
|
+
rows = realization.get("fileStructure")
|
|
195
|
+
if isinstance(rows, list):
|
|
196
|
+
carried["fileStructure"] = [
|
|
197
|
+
row for row in rows
|
|
198
|
+
if not isinstance(row, Mapping)
|
|
199
|
+
or not isinstance(row.get("path"), str)
|
|
200
|
+
or row["path"] in cited_text
|
|
201
|
+
]
|
|
202
|
+
return carried
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cites(text: str, identifier: str) -> bool:
|
|
206
|
+
return re.search(rf"(?<![\w-]){re.escape(identifier)}(?![\w-])", text) is not None
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""브리프 본문이 인용한 로컬 문서와 Source Material 항목을 맞춘다.
|
|
2
|
+
|
|
3
|
+
task worktree 는 체크아웃이라 프로젝트 루트의 git 미추적 파일(`.project-docs/` 스펙)이
|
|
4
|
+
없다. prepare 는 Source Material 의 `### Source N — file` 항목만 읽어 패킷에 싣으므로
|
|
5
|
+
(`analysis_packet.cited_documents_block`), 본문에만 이름이 나온 미추적 문서는 워커에게
|
|
6
|
+
닿지 않는다(fontradar dev-11562). `uncited_local_documents` 는 그런 경로를 찾고,
|
|
7
|
+
`validators/validate-brief.py` 가 이를 거절한다. `attach_missing_sources` 는 기존
|
|
8
|
+
브리프에 빠진 항목을 `format: by-reference` 로 덧붙인다. 다른 줄은 바꾸지 않는다.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .analysis_packet import BY_REFERENCE_FORMAT, _fence_mask, _section_map, file_source_entries
|
|
20
|
+
|
|
21
|
+
SOURCE_MATERIAL = "Source Material"
|
|
22
|
+
ATTACH_FETCHED_VIA = "okstra brief-sources attach"
|
|
23
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
24
|
+
_FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
|
|
25
|
+
_TOKEN_RE = re.compile(r"[^\s`'\"()\[\]<>{},;|*]+")
|
|
26
|
+
_LINE_SUFFIX_RE = re.compile(r"(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)\Z")
|
|
27
|
+
_SOURCE_NUMBER_RE = re.compile(r"\A### Source (\d+)\b")
|
|
28
|
+
# okstra 자신의 산출물(브리프, 그룹 맥락, 보고서)은 okstra 가 직접 운반한다.
|
|
29
|
+
_OKSTRA_HOME = ".okstra"
|
|
30
|
+
# 문서만 본다. 미추적 코드 경로(`node_modules/` 타입 선언 등)는 설치로 다시 생기는 근거라
|
|
31
|
+
# 브리프의 출처 문서가 아니다.
|
|
32
|
+
DOCUMENT_SUFFIXES = frozenset({".md", ".markdown", ".txt", ".rst", ".adoc"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _body_lines(text: str) -> list[str]:
|
|
36
|
+
return _FRONTMATTER_RE.sub("", _HTML_COMMENT_RE.sub("", text), count=1).splitlines()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _project_file(token: str, root: Path) -> str | None:
|
|
40
|
+
"""토큰이 프로젝트 루트 아래 파일을 가리키면 루트 기준 posix 경로를 돌려준다."""
|
|
41
|
+
if "/" not in token or "://" in token:
|
|
42
|
+
return None
|
|
43
|
+
token = _LINE_SUFFIX_RE.sub("", token.rstrip(".:"))
|
|
44
|
+
candidate = Path(token).expanduser()
|
|
45
|
+
path = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
46
|
+
try:
|
|
47
|
+
relative = path.relative_to(root)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
if (not relative.parts or relative.parts[0] == _OKSTRA_HOME
|
|
51
|
+
or path.suffix.lower() not in DOCUMENT_SUFFIXES or not path.is_file()):
|
|
52
|
+
return None
|
|
53
|
+
return relative.as_posix()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cited_in_body(text: str, root: Path) -> list[str]:
|
|
57
|
+
paths: dict[str, None] = {}
|
|
58
|
+
lines = _body_lines(text)
|
|
59
|
+
section = ""
|
|
60
|
+
for line, fenced in zip(lines, _fence_mask(lines)):
|
|
61
|
+
if fenced:
|
|
62
|
+
continue
|
|
63
|
+
if line.startswith("## "):
|
|
64
|
+
section = line[3:].strip()
|
|
65
|
+
continue
|
|
66
|
+
if section == SOURCE_MATERIAL:
|
|
67
|
+
continue
|
|
68
|
+
for token in _TOKEN_RE.findall(line):
|
|
69
|
+
if (relative := _project_file(token, root)) is not None:
|
|
70
|
+
paths.setdefault(relative)
|
|
71
|
+
return list(paths)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sourced(text: str, root: Path) -> set[str]:
|
|
75
|
+
source_material = _section_map(_HTML_COMMENT_RE.sub("", text)).get(SOURCE_MATERIAL, "")
|
|
76
|
+
return {
|
|
77
|
+
relative
|
|
78
|
+
for ref, _ in file_source_entries(source_material)
|
|
79
|
+
for token in _TOKEN_RE.findall(ref)
|
|
80
|
+
if (relative := _project_file(token, root)) is not None
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _git_tracked(root: Path, paths: list[str]) -> set[str] | None:
|
|
85
|
+
"""`paths` 중 git 이 추적하는 것. git 저장소가 아니면 None — worktree 가 없으니 검사할 것도 없다."""
|
|
86
|
+
proc = subprocess.run(
|
|
87
|
+
["git", "-C", str(root), "ls-files", "-z", "--", *paths],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
if proc.returncode != 0:
|
|
93
|
+
return None
|
|
94
|
+
return {name for name in proc.stdout.split("\0") if name}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def uncited_local_documents(text: str, project_root: Path) -> list[str]:
|
|
98
|
+
"""본문이 이름을 댄 git 미추적 프로젝트 파일 중 Source Material 의 file 항목이 없는 것."""
|
|
99
|
+
root = project_root.resolve()
|
|
100
|
+
cited = [path for path in _cited_in_body(text, root) if path not in _sourced(text, root)]
|
|
101
|
+
if not cited:
|
|
102
|
+
return []
|
|
103
|
+
tracked = _git_tracked(root, cited)
|
|
104
|
+
if tracked is None:
|
|
105
|
+
return []
|
|
106
|
+
return [path for path in cited if path not in tracked]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _source_entry(index: int, ref: str, fetched_at: str) -> list[str]:
|
|
110
|
+
return [
|
|
111
|
+
f"### Source {index} — file",
|
|
112
|
+
"",
|
|
113
|
+
f"- ref: {ref}",
|
|
114
|
+
f"- fetched-via: {ATTACH_FETCHED_VIA}",
|
|
115
|
+
f"- fetched-at: {fetched_at}",
|
|
116
|
+
f"- format: {BY_REFERENCE_FORMAT}",
|
|
117
|
+
"",
|
|
118
|
+
"```",
|
|
119
|
+
"```",
|
|
120
|
+
"",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def attach_missing_sources(brief_path: Path, project_root: Path, *, now: dt.datetime) -> list[str]:
|
|
125
|
+
"""빠진 file 항목을 Source Material 끝에 덧붙이고 덧붙인 경로를 돌려준다."""
|
|
126
|
+
text = brief_path.read_text(encoding="utf-8")
|
|
127
|
+
missing = uncited_local_documents(text, project_root)
|
|
128
|
+
if not missing:
|
|
129
|
+
return []
|
|
130
|
+
lines = text.split("\n")
|
|
131
|
+
mask = _fence_mask(lines)
|
|
132
|
+
start = next(
|
|
133
|
+
(i for i, (line, fenced) in enumerate(zip(lines, mask))
|
|
134
|
+
if not fenced and line.strip() == f"## {SOURCE_MATERIAL}"),
|
|
135
|
+
None,
|
|
136
|
+
)
|
|
137
|
+
if start is None:
|
|
138
|
+
raise ValueError(f"{brief_path}: no `## {SOURCE_MATERIAL}` section to attach to")
|
|
139
|
+
end = next(
|
|
140
|
+
(i for i in range(start + 1, len(lines)) if not mask[i] and lines[i].startswith("## ")),
|
|
141
|
+
len(lines),
|
|
142
|
+
)
|
|
143
|
+
numbers = [int(m.group(1)) for i in range(start + 1, end) if not mask[i]
|
|
144
|
+
if (m := _SOURCE_NUMBER_RE.match(lines[i]))]
|
|
145
|
+
while end > start + 1 and not lines[end - 1].strip():
|
|
146
|
+
end -= 1
|
|
147
|
+
stamp = now.strftime("%Y-%m-%d %H:%M")
|
|
148
|
+
entries: list[str] = [""]
|
|
149
|
+
for offset, ref in enumerate(missing, max(numbers, default=0) + 1):
|
|
150
|
+
entries.extend(_source_entry(offset, ref, stamp))
|
|
151
|
+
tail = lines[end:]
|
|
152
|
+
while tail and not tail[0].strip():
|
|
153
|
+
tail.pop(0)
|
|
154
|
+
brief_path.write_text("\n".join([*lines[:end], *entries, *tail]), encoding="utf-8")
|
|
155
|
+
return missing
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _attach_command(args: argparse.Namespace) -> int:
|
|
159
|
+
brief = Path(args.brief).resolve()
|
|
160
|
+
try:
|
|
161
|
+
added = attach_missing_sources(brief, Path(args.project_root), now=dt.datetime.now())
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
print(f"brief-sources: {exc}", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
if not added:
|
|
166
|
+
print(f"brief-sources: nothing to attach in {brief}")
|
|
167
|
+
return 0
|
|
168
|
+
print(f"brief-sources: attached {len(added)} Source Material entr{'y' if len(added) == 1 else 'ies'} to {brief}")
|
|
169
|
+
for path in added:
|
|
170
|
+
print(f" - {path} (format: {BY_REFERENCE_FORMAT})")
|
|
171
|
+
print("Every later prepare copies these files read-only into the analysis packet's `## Cited Local Documents`.")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
176
|
+
parser = argparse.ArgumentParser(
|
|
177
|
+
prog="okstra brief-sources",
|
|
178
|
+
description=(
|
|
179
|
+
"Give every untracked project file a brief's body cites its own "
|
|
180
|
+
"Source Material entry, so runs copy it into the analysis packet."
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
184
|
+
attach = sub.add_parser(
|
|
185
|
+
"attach",
|
|
186
|
+
help="append a by-reference `### Source N — file` entry for each cited untracked file; edits nothing else",
|
|
187
|
+
)
|
|
188
|
+
attach.add_argument("--project-root", required=True, help="project root that holds `.okstra/`")
|
|
189
|
+
attach.add_argument("--brief", required=True, help="brief markdown file to upgrade in place")
|
|
190
|
+
attach.set_defaults(func=_attach_command)
|
|
191
|
+
return parser
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: list[str] | None = None) -> int:
|
|
195
|
+
args = build_parser().parse_args(argv)
|
|
196
|
+
return args.func(args)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
sys.exit(main())
|