okstra 0.179.2 → 0.183.2
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 +1 -1
- package/dist/cli-registry.mjs +30 -2
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/incremental-carry.mjs +9 -8
- package/dist/commands/execute/incremental-carry.mjs.map +1 -1
- package/dist/commands/execute/plan-verify.mjs +3 -1
- package/dist/commands/execute/plan-verify.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.d.mts +4 -2
- package/dist/commands/execute/render-bundle.mjs +46 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/run.mjs +11 -3
- package/dist/commands/execute/run.mjs.map +1 -1
- package/dist/commands/inspect/model-io.d.mts +1 -0
- package/dist/commands/inspect/model-io.mjs +25 -0
- package/dist/commands/inspect/model-io.mjs.map +1 -0
- package/dist/commands/inspect/stage-map.mjs +29 -8
- package/dist/commands/inspect/stage-map.mjs.map +1 -1
- package/dist/commands/inspect/task-list.mjs +52 -6
- package/dist/commands/inspect/task-list.mjs.map +1 -1
- package/dist/commands/inspect/user-response.mjs +14 -4
- package/dist/commands/inspect/user-response.mjs.map +1 -1
- package/dist/commands/lifecycle/check-project.d.mts +1 -0
- package/dist/commands/lifecycle/check-project.mjs +69 -50
- package/dist/commands/lifecycle/check-project.mjs.map +1 -1
- package/dist/commands/lifecycle/contract-check.d.mts +1 -0
- package/dist/commands/lifecycle/contract-check.mjs +18 -0
- package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
- package/dist/commands/lifecycle/preflight.mjs +154 -51
- package/dist/commands/lifecycle/preflight.mjs.map +1 -1
- package/dist/commands/pr/pr.d.mts +1 -0
- package/dist/commands/pr/pr.mjs +19 -1
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/dist/commands/report/agent-activity.mjs +2 -2
- package/dist/commands/report/approval-decision.d.mts +1 -0
- package/dist/commands/report/approval-decision.mjs +21 -0
- package/dist/commands/report/approval-decision.mjs.map +1 -0
- package/dist/commands/report/design-snapshot.d.mts +1 -0
- package/dist/commands/report/design-snapshot.mjs +19 -0
- package/dist/commands/report/design-snapshot.mjs.map +1 -0
- package/dist/commands/report/translate.mjs +3 -0
- package/dist/commands/report/translate.mjs.map +1 -1
- package/dist/lib/host-registry-client.mjs +13 -9
- package/dist/lib/host-registry-client.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +21 -10
- package/docs/cli.md +36 -20
- package/docs/container.md +6 -4
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
- package/docs/for-ai/skills/okstra-code-review.md +4 -4
- package/docs/for-ai/skills/okstra-container-build.md +20 -17
- package/docs/for-ai/skills/okstra-inspect.md +20 -23
- package/docs/for-ai/skills/okstra-manager.md +19 -18
- package/docs/for-ai/skills/okstra-memory.md +2 -2
- package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
- package/docs/for-ai/skills/okstra-rollup.md +14 -13
- package/docs/for-ai/skills/okstra-run.md +7 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
- package/docs/for-ai/skills/okstra-setup.md +7 -7
- package/docs/for-ai/skills/okstra-usage.md +5 -4
- package/docs/for-ai/skills/okstra-user-response.md +50 -32
- package/docs/project-structure-overview.md +43 -31
- package/docs/task-process/implementation-planning.md +2 -2
- package/docs/task-process/implementation.md +7 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/agents/workers/translator-worker.md +5 -13
- package/runtime/bin/okstra-error-log.py +51 -11
- package/runtime/bin/okstra-report-translate.py +210 -23
- package/runtime/prompts/host-orchestration/implementation.md +1 -1
- package/runtime/prompts/launch.template.md +9 -12
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +10 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
- package/runtime/prompts/lead/plan-body-verification.md +217 -23
- package/runtime/prompts/lead/report-writer.md +81 -421
- package/runtime/prompts/lead/team-contract.md +9 -54
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/feature-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +20 -12
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +0 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
- package/runtime/python/okstra_ctl/agent_activity.py +141 -38
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
- package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
- package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
- package/runtime/python/okstra_ctl/backfill.py +13 -10
- package/runtime/python/okstra_ctl/batch.py +2 -4
- package/runtime/python/okstra_ctl/build_tools.py +6 -3
- package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
- package/runtime/python/okstra_ctl/clarification_items.py +27 -13
- package/runtime/python/okstra_ctl/cmux.py +130 -52
- package/runtime/python/okstra_ctl/code_review_target.py +34 -8
- package/runtime/python/okstra_ctl/consumers.py +5 -4
- package/runtime/python/okstra_ctl/container.py +103 -8
- package/runtime/python/okstra_ctl/context_cost.py +2 -1
- package/runtime/python/okstra_ctl/contract_graph.py +497 -0
- package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
- package/runtime/python/okstra_ctl/convergence.py +338 -17
- package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
- package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
- package/runtime/python/okstra_ctl/convergence_store.py +55 -34
- package/runtime/python/okstra_ctl/design_prep.py +7 -4
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
- package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
- package/runtime/python/okstra_ctl/doctor.py +6 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
- package/runtime/python/okstra_ctl/error_log_write.py +35 -30
- package/runtime/python/okstra_ctl/error_report.py +26 -1
- package/runtime/python/okstra_ctl/error_zip.py +27 -5
- package/runtime/python/okstra_ctl/execution_identity.py +3 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
- package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
- package/runtime/python/okstra_ctl/fixed_text.py +39 -0
- package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
- package/runtime/python/okstra_ctl/handoff.py +5 -4
- package/runtime/python/okstra_ctl/i18n.py +4 -2
- package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
- package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
- package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
- package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
- package/runtime/python/okstra_ctl/index.py +8 -11
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
- package/runtime/python/okstra_ctl/invocation.py +3 -6
- package/runtime/python/okstra_ctl/json_boundary.py +366 -0
- package/runtime/python/okstra_ctl/json_registry.py +10 -12
- package/runtime/python/okstra_ctl/jsonl.py +19 -2
- package/runtime/python/okstra_ctl/lead_events.py +33 -1
- package/runtime/python/okstra_ctl/listing.py +3 -3
- package/runtime/python/okstra_ctl/log_report.py +24 -2
- package/runtime/python/okstra_ctl/manager_cli.py +92 -7
- package/runtime/python/okstra_ctl/manager_store.py +12 -10
- package/runtime/python/okstra_ctl/material.py +5 -1
- package/runtime/python/okstra_ctl/migrate.py +29 -25
- package/runtime/python/okstra_ctl/model_cli.py +3 -15
- package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
- package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +21 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/pr_template.py +3 -2
- package/runtime/python/okstra_ctl/project_meta.py +5 -7
- package/runtime/python/okstra_ctl/recap.py +5 -4
- package/runtime/python/okstra_ctl/reconcile.py +21 -27
- package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
- package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
- package/runtime/python/okstra_ctl/render.py +74 -16
- package/runtime/python/okstra_ctl/render_final_report.py +10 -8
- package/runtime/python/okstra_ctl/report_assembly.py +509 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +67 -32
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +81 -8
- package/runtime/python/okstra_ctl/report_narrative.py +351 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
- package/runtime/python/okstra_ctl/report_views.py +3 -2
- package/runtime/python/okstra_ctl/rollup.py +65 -4
- package/runtime/python/okstra_ctl/run.py +184 -65
- package/runtime/python/okstra_ctl/run_audit.py +3 -2
- package/runtime/python/okstra_ctl/run_context.py +6 -9
- package/runtime/python/okstra_ctl/run_index_row.py +2 -8
- package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
- package/runtime/python/okstra_ctl/session_transcript.py +27 -1
- package/runtime/python/okstra_ctl/set_work_status.py +64 -38
- package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +96 -28
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +3 -2
- package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
- package/runtime/python/okstra_ctl/time_report.py +51 -4
- package/runtime/python/okstra_ctl/usage_identity.py +2 -1
- package/runtime/python/okstra_ctl/usage_report.py +58 -4
- package/runtime/python/okstra_ctl/user_response.py +1431 -66
- package/runtime/python/okstra_ctl/wizard.py +11 -44
- package/runtime/python/okstra_ctl/work_categories.py +3 -2
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/python/okstra_ctl/worker_runner.py +14 -12
- package/runtime/python/okstra_ctl/workflow.py +2 -1
- package/runtime/python/okstra_ctl/worktree.py +3 -2
- package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
- package/runtime/python/okstra_ctl/write_policy.py +4 -2
- package/runtime/python/okstra_token_usage/antigravity.py +39 -12
- package/runtime/python/okstra_token_usage/collect.py +90 -38
- package/runtime/python/okstra_token_usage/grok.py +127 -0
- package/runtime/schemas/final-report-v2.0.schema.json +9 -0
- package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
- package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
- package/runtime/skills/okstra-code-review/SKILL.md +21 -11
- package/runtime/skills/okstra-container-build/SKILL.md +18 -18
- package/runtime/skills/okstra-inspect/SKILL.md +12 -11
- package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
- package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
- package/runtime/skills/okstra-inspect/facets/history.md +9 -14
- package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
- package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
- package/runtime/skills/okstra-inspect/facets/report.md +6 -10
- package/runtime/skills/okstra-inspect/facets/status.md +9 -8
- package/runtime/skills/okstra-inspect/facets/time.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +16 -14
- package/runtime/skills/okstra-memory/SKILL.md +3 -3
- package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
- package/runtime/skills/okstra-rollup/SKILL.md +6 -16
- package/runtime/skills/okstra-run/SKILL.md +7 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
- package/runtime/skills/okstra-setup/SKILL.md +21 -13
- package/runtime/skills/okstra-setup/references/project-config.md +2 -2
- package/runtime/skills/okstra-usage/SKILL.md +10 -10
- package/runtime/skills/okstra-user-response/SKILL.md +78 -107
- package/runtime/templates/report-writer-prompt-preamble.md +29 -19
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +636 -72
- package/runtime/validators/validate_session_conformance.py +43 -7
|
@@ -101,6 +101,7 @@ def build_analysis_packet(
|
|
|
101
101
|
instruction_set_relative_path: str,
|
|
102
102
|
fix_history_text: str = "",
|
|
103
103
|
stage_ledger_json: str = "",
|
|
104
|
+
stage_ledger_notice: str = "",
|
|
104
105
|
) -> str:
|
|
105
106
|
"""Return the primary compact input for Claude/Codex/Antigravity analysers."""
|
|
106
107
|
brief_text = task_brief_path.read_text(encoding="utf-8")
|
|
@@ -124,9 +125,90 @@ def build_analysis_packet(
|
|
|
124
125
|
parts.extend(_reference_block(reference_text))
|
|
125
126
|
parts.extend(_fix_history_block(fix_history_text))
|
|
126
127
|
parts.extend(_stage_ledger_block(stage_ledger_json))
|
|
128
|
+
parts.extend(_stage_ledger_unavailable_block(stage_ledger_notice))
|
|
127
129
|
parts.extend(_clarification_block(clarification_text))
|
|
128
130
|
parts.extend(_directive_block(directive))
|
|
129
|
-
|
|
131
|
+
body = "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
|
|
132
|
+
return _with_section_index(body)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_HEADING_RE = re.compile(r"\A#{1,2} \S")
|
|
136
|
+
_FENCE_RE = re.compile(r"\A\s*(```|~~~)")
|
|
137
|
+
# 목차 자신이 차지하는 줄 수 중 표제 행을 뺀 나머지. 행 수는 표제 개수로
|
|
138
|
+
# 정해지므로 삽입 전에 전체 이동량을 계산할 수 있다.
|
|
139
|
+
_INDEX_PREAMBLE_LINES = 5
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _with_section_index(body: str) -> str:
|
|
143
|
+
"""Prefix the rendered packet with every section's line range.
|
|
144
|
+
|
|
145
|
+
The packet's own structure is the only thing a worker can navigate it by,
|
|
146
|
+
and a worker cannot navigate what it cannot see. Measured 2026-08-21: the
|
|
147
|
+
antigravity CLI's `view_file` truncates at 46,080 bytes per call, carries
|
|
148
|
+
no line-range parameter, and reports only `<n> lines, <m> bytes` — the same
|
|
149
|
+
string whether it returned the file or 40% of it. On a 114KB packet that
|
|
150
|
+
first call ended at line 510, so `## Stage Ledger` (572) and
|
|
151
|
+
`## Clarification Carry-In Extract` (680) never arrived and the worker
|
|
152
|
+
planned from the pre-migration brief that fills the first 42KB. Every
|
|
153
|
+
evidence line it cited was under 400. With the ranges here a truncated
|
|
154
|
+
reader can fetch the block it is missing by range instead of re-reading
|
|
155
|
+
the head; the lead had to write those ranges by hand to get a usable
|
|
156
|
+
retry.
|
|
157
|
+
"""
|
|
158
|
+
lines = body.splitlines()
|
|
159
|
+
anchor = next(
|
|
160
|
+
(i for i, line in enumerate(lines)
|
|
161
|
+
if line.startswith("# OKSTRA Analysis Packet")),
|
|
162
|
+
None,
|
|
163
|
+
)
|
|
164
|
+
if anchor is None:
|
|
165
|
+
return body
|
|
166
|
+
headings = _heading_lines(lines)
|
|
167
|
+
if not headings:
|
|
168
|
+
return body
|
|
169
|
+
shift = _INDEX_PREAMBLE_LINES + len(headings)
|
|
170
|
+
total = len(lines) + shift
|
|
171
|
+
starts = [number + shift for number, _ in headings]
|
|
172
|
+
rows = [
|
|
173
|
+
f"- {start}-{end} `{text}`"
|
|
174
|
+
for start, end, (_, text) in zip(
|
|
175
|
+
starts, [nxt - 1 for nxt in starts[1:]] + [total], headings
|
|
176
|
+
)
|
|
177
|
+
]
|
|
178
|
+
block = [
|
|
179
|
+
"",
|
|
180
|
+
"## Section Index",
|
|
181
|
+
"",
|
|
182
|
+
f"This packet is {total} lines. Read a section by line range with"
|
|
183
|
+
" `sed -n '<start>,<end>p'` rather than opening the whole file: a"
|
|
184
|
+
" whole-file read is truncated by some worker CLIs without reporting"
|
|
185
|
+
" it, and the sections that describe current state are last.",
|
|
186
|
+
"",
|
|
187
|
+
*rows,
|
|
188
|
+
]
|
|
189
|
+
assert len(block) == shift, (len(block), shift)
|
|
190
|
+
return "\n".join(lines[:anchor + 1] + block + lines[anchor + 1:]) + "\n"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _heading_lines(lines: list[str]) -> list[tuple[int, str]]:
|
|
194
|
+
"""Every `#`/`##` heading outside a fence, as (1-based line, text).
|
|
195
|
+
|
|
196
|
+
Fences are tracked because the Stage Ledger and the carried-in
|
|
197
|
+
clarification rows embed JSON and shell text where a `#` starts a comment,
|
|
198
|
+
not a section.
|
|
199
|
+
"""
|
|
200
|
+
out: list[tuple[int, str]] = []
|
|
201
|
+
fenced = False
|
|
202
|
+
for number, line in enumerate(lines, start=1):
|
|
203
|
+
if _FENCE_RE.match(line):
|
|
204
|
+
fenced = not fenced
|
|
205
|
+
continue
|
|
206
|
+
if fenced or not _HEADING_RE.match(line):
|
|
207
|
+
continue
|
|
208
|
+
if line.startswith("# OKSTRA Analysis Packet"):
|
|
209
|
+
continue
|
|
210
|
+
out.append((number, line.strip()))
|
|
211
|
+
return out
|
|
130
212
|
|
|
131
213
|
|
|
132
214
|
def _packet_frontmatter(brief_text: str, task_key: str) -> str:
|
|
@@ -240,8 +322,20 @@ def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
|
|
|
240
322
|
"",
|
|
241
323
|
"Facts about this task's stages as they stand on disk — not a plan.",
|
|
242
324
|
"A stage whose `status` is `done` is already implemented and will not",
|
|
243
|
-
"be executed again, so do not rewrite its plan body.
|
|
244
|
-
"
|
|
325
|
+
"be executed again, so do not rewrite its plan body.",
|
|
326
|
+
"",
|
|
327
|
+
"`stages` lists every stage the latest plan declares, so every number",
|
|
328
|
+
"in it is taken. Never reuse or renumber one: a new stage takes the",
|
|
329
|
+
"next number after the highest listed here, and reworking a completed",
|
|
330
|
+
"stage means cancelling it and adding a new number, never editing it",
|
|
331
|
+
"in place. `sourcePlan` is the plan the completed stages were built",
|
|
332
|
+
"against; `latestPlan` is the plan this list came from. When the two",
|
|
333
|
+
"differ, the completed work followed the former and the numbering",
|
|
334
|
+
"authority is the latter.",
|
|
335
|
+
"",
|
|
336
|
+
"A `planDivergence` entry means the two plans disagree about a",
|
|
337
|
+
"completed stage. Treat it as a blocker for any new stage number and",
|
|
338
|
+
"report it; do not resolve it by choosing one of the two yourself.",
|
|
245
339
|
"",
|
|
246
340
|
"```json",
|
|
247
341
|
stage_ledger_json.strip(),
|
|
@@ -250,6 +344,33 @@ def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
|
|
|
250
344
|
]
|
|
251
345
|
|
|
252
346
|
|
|
347
|
+
def _stage_ledger_unavailable_block(notice: str) -> list[str]:
|
|
348
|
+
"""원장을 못 읽었다는 사실을 packet 에 남긴다.
|
|
349
|
+
|
|
350
|
+
블록을 생략하면 저작 쪽은 "이전 계획이 없다" 로 읽는다 — 프로파일이 그렇게
|
|
351
|
+
지시한다. 계획이 있는데 못 읽은 경우에 그 침묵은 거짓이고, 이미 점유된
|
|
352
|
+
번호를 새 stage 에 다시 내주는 경로가 된다.
|
|
353
|
+
"""
|
|
354
|
+
if not notice.strip():
|
|
355
|
+
return []
|
|
356
|
+
return [
|
|
357
|
+
"",
|
|
358
|
+
"## Stage Ledger",
|
|
359
|
+
"",
|
|
360
|
+
"This task's stage facts could NOT be read, so no ledger is included.",
|
|
361
|
+
"Do not read this as 'the task has no prior plan' — a plan exists and",
|
|
362
|
+
"this run could not parse it.",
|
|
363
|
+
"",
|
|
364
|
+
f"- Reason: {notice.strip()}",
|
|
365
|
+
"",
|
|
366
|
+
"Do not assign a number to any new stage in this state. A number taken",
|
|
367
|
+
"by a plan this run could not read would collide with the completed",
|
|
368
|
+
"work under it, and that collision stays silent until integration.",
|
|
369
|
+
"Report this as a blocker instead.",
|
|
370
|
+
"",
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
|
|
253
374
|
def _clarification_block(clarification_text: str) -> list[str]:
|
|
254
375
|
if not clarification_text.strip():
|
|
255
376
|
return []
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""선임 에이전트가 소유하는 승인 결정 입력 원장."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from .convergence_store import write_json_atomic
|
|
14
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DISPOSITIONS = frozenset({"select", "accept-risk", "request-revision", "reject"})
|
|
18
|
+
REACHES = frozenset({"in-repo", "cross-repo"})
|
|
19
|
+
SCOPE_EFFECTS = frozenset({"new-schema", "deferrable"})
|
|
20
|
+
CLASSIFICATIONS = frozenset(
|
|
21
|
+
{"user-decision", "noncritical-dissent", "correctness-critical"}
|
|
22
|
+
)
|
|
23
|
+
_FORBIDDEN_BY_CLASSIFICATION = {
|
|
24
|
+
"correctness-critical": frozenset({"select", "accept-risk"}),
|
|
25
|
+
"noncritical-dissent": frozenset({"select"}),
|
|
26
|
+
"user-decision": frozenset(),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApprovalDecisionError(ValueError):
|
|
31
|
+
"""승인 결정 입력이 역할 계약이나 선택지 불변식을 위반했다."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _required(value: str, field: str) -> str:
|
|
35
|
+
if not isinstance(value, str) or not value.strip():
|
|
36
|
+
raise ApprovalDecisionError(f"{field} must be a non-empty string")
|
|
37
|
+
return value.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DecisionOption:
|
|
42
|
+
role: str
|
|
43
|
+
answer: str
|
|
44
|
+
rationale: str
|
|
45
|
+
disposition: str
|
|
46
|
+
reach: str
|
|
47
|
+
scope_effects: tuple[str, ...]
|
|
48
|
+
added_work: str
|
|
49
|
+
direction_change: str
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if self.role not in {"recommended", "alternative"}:
|
|
53
|
+
raise ApprovalDecisionError(f"invalid option role: {self.role}")
|
|
54
|
+
for field in ("answer", "rationale", "added_work", "direction_change"):
|
|
55
|
+
_required(getattr(self, field), field)
|
|
56
|
+
if self.disposition not in DISPOSITIONS:
|
|
57
|
+
raise ApprovalDecisionError(f"invalid disposition: {self.disposition}")
|
|
58
|
+
if self.reach not in REACHES:
|
|
59
|
+
raise ApprovalDecisionError(f"invalid reach: {self.reach}")
|
|
60
|
+
unknown = sorted(set(self.scope_effects) - SCOPE_EFFECTS)
|
|
61
|
+
if unknown or len(set(self.scope_effects)) != len(self.scope_effects):
|
|
62
|
+
raise ApprovalDecisionError(f"invalid scope_effects: {unknown}")
|
|
63
|
+
|
|
64
|
+
def to_payload(self) -> dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"role": self.role,
|
|
67
|
+
"answer": self.answer,
|
|
68
|
+
"rationale": self.rationale,
|
|
69
|
+
"disposition": self.disposition,
|
|
70
|
+
"reach": self.reach,
|
|
71
|
+
"scopeEffects": list(self.scope_effects),
|
|
72
|
+
"addedWork": self.added_work,
|
|
73
|
+
"directionChange": self.direction_change,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _new_ledger(task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
|
|
78
|
+
return {
|
|
79
|
+
"schemaVersion": "1.0",
|
|
80
|
+
"owner": "lead",
|
|
81
|
+
"taskKey": _required(task_key, "task_key"),
|
|
82
|
+
"taskType": _required(task_type, "task_type"),
|
|
83
|
+
"runSeq": _required(run_seq, "run_seq"),
|
|
84
|
+
"activeClarifications": [],
|
|
85
|
+
"carriedDecisions": [],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_ledger(path: Path) -> dict[str, Any]:
|
|
90
|
+
try:
|
|
91
|
+
value = load_owned_object(path, artifact="approval decision ledger")
|
|
92
|
+
except JsonBoundaryError as exc:
|
|
93
|
+
raise ApprovalDecisionError(f"cannot read approval ledger {path}: {exc}") from exc
|
|
94
|
+
if not isinstance(value, dict) or value.get("owner") != "lead":
|
|
95
|
+
raise ApprovalDecisionError(f"approval ledger owner must be lead: {path}")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ledger(path: Path, task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
|
|
100
|
+
if not path.is_file():
|
|
101
|
+
return _new_ledger(task_key, task_type, run_seq)
|
|
102
|
+
ledger = _read_ledger(path)
|
|
103
|
+
expected = (task_key, task_type, run_seq)
|
|
104
|
+
actual = (ledger.get("taskKey"), ledger.get("taskType"), ledger.get("runSeq"))
|
|
105
|
+
if actual != expected:
|
|
106
|
+
raise ApprovalDecisionError("approval ledger identity does not match this run")
|
|
107
|
+
return ledger
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_option_set(
|
|
111
|
+
options: Sequence[DecisionOption], classification: str,
|
|
112
|
+
recommended_disposition: str,
|
|
113
|
+
) -> None:
|
|
114
|
+
if len(options) < 2:
|
|
115
|
+
raise ApprovalDecisionError("a decision requires at least two options")
|
|
116
|
+
if sum(option.role == "recommended" for option in options) != 1:
|
|
117
|
+
raise ApprovalDecisionError("a decision requires exactly one recommended option")
|
|
118
|
+
forbidden = _FORBIDDEN_BY_CLASSIFICATION[classification]
|
|
119
|
+
used = {recommended_disposition, *(option.disposition for option in options)}
|
|
120
|
+
invalid = sorted(used & forbidden)
|
|
121
|
+
if invalid:
|
|
122
|
+
raise ApprovalDecisionError(f"{classification} forbids dispositions: {invalid}")
|
|
123
|
+
recommended = next(option for option in options if option.role == "recommended")
|
|
124
|
+
if recommended.disposition != recommended_disposition:
|
|
125
|
+
raise ApprovalDecisionError(
|
|
126
|
+
"recommended_disposition must match the recommended option"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _decision_row(
|
|
131
|
+
*, clarification_id: str, ticket_id: str, statement: str,
|
|
132
|
+
expected_form: str, classification: str, origin: str,
|
|
133
|
+
user_confirmation: str, unblock_condition: str,
|
|
134
|
+
recommended_disposition: str, options: Sequence[DecisionOption],
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
if classification not in CLASSIFICATIONS:
|
|
137
|
+
raise ApprovalDecisionError(f"invalid classification: {classification}")
|
|
138
|
+
if recommended_disposition not in DISPOSITIONS:
|
|
139
|
+
raise ApprovalDecisionError(
|
|
140
|
+
f"invalid recommended_disposition: {recommended_disposition}"
|
|
141
|
+
)
|
|
142
|
+
_validate_option_set(options, classification, recommended_disposition)
|
|
143
|
+
return {
|
|
144
|
+
"id": _required(clarification_id, "clarification_id"),
|
|
145
|
+
"ticketId": _required(ticket_id, "ticket_id"),
|
|
146
|
+
"kind": "decision",
|
|
147
|
+
"statement": _required(statement, "statement"),
|
|
148
|
+
"expectedForm": _required(expected_form, "expected_form"),
|
|
149
|
+
"blocks": "approval",
|
|
150
|
+
"origin": _required(origin, "origin"),
|
|
151
|
+
"userConfirmation": _required(user_confirmation, "user_confirmation"),
|
|
152
|
+
"approval": {
|
|
153
|
+
"classification": classification,
|
|
154
|
+
"unblockCondition": _required(unblock_condition, "unblock_condition"),
|
|
155
|
+
"recommendedDisposition": recommended_disposition,
|
|
156
|
+
},
|
|
157
|
+
"options": [option.to_payload() for option in options],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def open_decision(
|
|
162
|
+
*, ledger_path: Path, task_key: str, task_type: str, run_seq: str,
|
|
163
|
+
clarification_id: str, ticket_id: str, statement: str, expected_form: str,
|
|
164
|
+
classification: str, origin: str, user_confirmation: str,
|
|
165
|
+
unblock_condition: str, recommended_disposition: str,
|
|
166
|
+
options: Sequence[DecisionOption],
|
|
167
|
+
) -> None:
|
|
168
|
+
row = _decision_row(
|
|
169
|
+
clarification_id=clarification_id, ticket_id=ticket_id,
|
|
170
|
+
statement=statement, expected_form=expected_form,
|
|
171
|
+
classification=classification, origin=origin,
|
|
172
|
+
user_confirmation=user_confirmation, unblock_condition=unblock_condition,
|
|
173
|
+
recommended_disposition=recommended_disposition, options=options,
|
|
174
|
+
)
|
|
175
|
+
ledger = _ledger(ledger_path, task_key, task_type, run_seq)
|
|
176
|
+
active = ledger.get("activeClarifications")
|
|
177
|
+
if not isinstance(active, list):
|
|
178
|
+
raise ApprovalDecisionError("activeClarifications must be an array")
|
|
179
|
+
if any(item.get("id") == clarification_id for item in active if isinstance(item, Mapping)):
|
|
180
|
+
raise ApprovalDecisionError(f"duplicate active clarification: {clarification_id}")
|
|
181
|
+
active.append(row)
|
|
182
|
+
write_json_atomic(ledger_path, ledger)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_decision(
|
|
186
|
+
ledger_path: Path, clarification_id: str, *, disposition: str,
|
|
187
|
+
user_text: str, user_response_ref: str, check_refs: Sequence[str],
|
|
188
|
+
) -> None:
|
|
189
|
+
if disposition not in DISPOSITIONS:
|
|
190
|
+
raise ApprovalDecisionError(f"invalid disposition: {disposition}")
|
|
191
|
+
resolution = {
|
|
192
|
+
"disposition": disposition,
|
|
193
|
+
"userText": _required(user_text, "user_text"),
|
|
194
|
+
"userResponseRef": _required(user_response_ref, "user_response_ref"),
|
|
195
|
+
"checkRefs": [_required(ref, "check_refs") for ref in check_refs],
|
|
196
|
+
}
|
|
197
|
+
if not resolution["checkRefs"]:
|
|
198
|
+
raise ApprovalDecisionError("check_refs requires at least one value")
|
|
199
|
+
invalid_refs = [
|
|
200
|
+
ref for ref in resolution["checkRefs"]
|
|
201
|
+
if re.fullmatch(r"A-\d{3,}", ref) is None
|
|
202
|
+
]
|
|
203
|
+
if invalid_refs:
|
|
204
|
+
raise ApprovalDecisionError(f"check_refs must be activity IDs: {invalid_refs}")
|
|
205
|
+
ledger = _read_ledger(ledger_path)
|
|
206
|
+
active = ledger.get("activeClarifications")
|
|
207
|
+
rows = active if isinstance(active, list) else []
|
|
208
|
+
matches = [row for row in rows if isinstance(row, dict) and row.get("id") == clarification_id]
|
|
209
|
+
if len(matches) != 1:
|
|
210
|
+
raise ApprovalDecisionError(f"active clarification not found: {clarification_id}")
|
|
211
|
+
matches[0]["resolutionInput"] = resolution
|
|
212
|
+
write_json_atomic(ledger_path, ledger)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def carry_decision(
|
|
216
|
+
ledger_path: Path, *, source_run_ref: str, decision: Mapping[str, Any],
|
|
217
|
+
) -> None:
|
|
218
|
+
ledger = _read_ledger(ledger_path)
|
|
219
|
+
carried = ledger.get("carriedDecisions")
|
|
220
|
+
if not isinstance(carried, list):
|
|
221
|
+
raise ApprovalDecisionError("carriedDecisions must be an array")
|
|
222
|
+
row = {
|
|
223
|
+
"sourceRunRef": _required(source_run_ref, "source_run_ref"),
|
|
224
|
+
"decision": deepcopy(dict(decision)),
|
|
225
|
+
}
|
|
226
|
+
if row not in carried:
|
|
227
|
+
carried.append(row)
|
|
228
|
+
write_json_atomic(ledger_path, ledger)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _options_from_args(args: argparse.Namespace) -> tuple[DecisionOption, ...]:
|
|
232
|
+
fields = (
|
|
233
|
+
args.option_role, args.option_answer, args.option_rationale,
|
|
234
|
+
args.option_disposition, args.option_reach, args.option_scope_effect,
|
|
235
|
+
args.option_added_work, args.option_direction_change,
|
|
236
|
+
)
|
|
237
|
+
if len({len(values) for values in fields}) != 1:
|
|
238
|
+
raise ApprovalDecisionError("every repeated option field needs the same count")
|
|
239
|
+
return tuple(
|
|
240
|
+
DecisionOption(role, answer, rationale, disposition, reach,
|
|
241
|
+
tuple(effect.split(",")) if effect else (), added, direction)
|
|
242
|
+
for role, answer, rationale, disposition, reach, effect, added, direction
|
|
243
|
+
in zip(*fields, strict=True)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _add_open_arguments(parser: argparse.ArgumentParser) -> None:
|
|
248
|
+
required = (
|
|
249
|
+
"task-key", "task-type", "run-seq", "clarification-id", "ticket-id",
|
|
250
|
+
"statement", "expected-form", "classification", "origin",
|
|
251
|
+
"user-confirmation", "unblock-condition", "recommended-disposition",
|
|
252
|
+
)
|
|
253
|
+
for flag in required:
|
|
254
|
+
parser.add_argument(f"--{flag}", required=True)
|
|
255
|
+
repeated = (
|
|
256
|
+
"role", "answer", "rationale", "disposition", "reach",
|
|
257
|
+
"scope-effect", "added-work", "direction-change",
|
|
258
|
+
)
|
|
259
|
+
for flag in repeated:
|
|
260
|
+
parser.add_argument(f"--option-{flag}", action="append", default=[])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _parser() -> argparse.ArgumentParser:
|
|
264
|
+
parser = argparse.ArgumentParser(prog="okstra approval-decision")
|
|
265
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
266
|
+
opened = commands.add_parser("open")
|
|
267
|
+
opened.add_argument("--ledger", type=Path, required=True)
|
|
268
|
+
_add_open_arguments(opened)
|
|
269
|
+
resolved = commands.add_parser("resolve")
|
|
270
|
+
resolved.add_argument("--ledger", type=Path, required=True)
|
|
271
|
+
resolved.add_argument("--clarification-id", required=True)
|
|
272
|
+
resolved.add_argument("--disposition", required=True)
|
|
273
|
+
resolved.add_argument("--user-text", required=True)
|
|
274
|
+
resolved.add_argument("--user-response-ref", required=True)
|
|
275
|
+
resolved.add_argument("--check-ref", action="append", default=[])
|
|
276
|
+
carried = commands.add_parser("carry")
|
|
277
|
+
carried.add_argument("--ledger", type=Path, required=True)
|
|
278
|
+
carried.add_argument("--source-ledger", type=Path, required=True)
|
|
279
|
+
carried.add_argument("--source-run-ref", required=True)
|
|
280
|
+
carried.add_argument("--clarification-id", required=True)
|
|
281
|
+
return parser
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _open_from_args(args: argparse.Namespace) -> None:
|
|
285
|
+
values = vars(args).copy()
|
|
286
|
+
values.pop("command")
|
|
287
|
+
values["ledger_path"] = values.pop("ledger")
|
|
288
|
+
for key in tuple(values):
|
|
289
|
+
if key.startswith("option_"):
|
|
290
|
+
values.pop(key)
|
|
291
|
+
open_decision(**values, options=_options_from_args(args))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _carry_from_args(args: argparse.Namespace) -> None:
|
|
295
|
+
source = _read_ledger(args.source_ledger)
|
|
296
|
+
rows = source.get("activeClarifications") or []
|
|
297
|
+
matches = [row for row in rows if isinstance(row, dict) and row.get("id") == args.clarification_id]
|
|
298
|
+
if len(matches) != 1:
|
|
299
|
+
raise ApprovalDecisionError(f"source clarification not found: {args.clarification_id}")
|
|
300
|
+
carry_decision(args.ledger, source_run_ref=args.source_run_ref, decision=matches[0])
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _run(args: argparse.Namespace) -> None:
|
|
304
|
+
if args.command == "open":
|
|
305
|
+
_open_from_args(args)
|
|
306
|
+
elif args.command == "resolve":
|
|
307
|
+
resolve_decision(
|
|
308
|
+
args.ledger, args.clarification_id, disposition=args.disposition,
|
|
309
|
+
user_text=args.user_text, user_response_ref=args.user_response_ref,
|
|
310
|
+
check_refs=args.check_ref,
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
_carry_from_args(args)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv: list[str] | None = None) -> int:
|
|
317
|
+
try:
|
|
318
|
+
args = _parser().parse_args(argv)
|
|
319
|
+
_run(args)
|
|
320
|
+
print(json.dumps({"ok": True, "ledger": str(args.ledger)}, ensure_ascii=False))
|
|
321
|
+
return 0
|
|
322
|
+
except (ApprovalDecisionError, OSError) as exc:
|
|
323
|
+
print(f"approval-decision: {exc}", file=sys.stderr)
|
|
324
|
+
return 1
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
raise SystemExit(main())
|
|
@@ -12,6 +12,7 @@ from .domain.worker_stream import host_event_row
|
|
|
12
12
|
from .execution_identity import Attempt, ExecutionManifestError
|
|
13
13
|
from .execution_manifest import read_execution_manifest
|
|
14
14
|
from .execution_manifest import _replace_manifest, _write_manifest_over_existing
|
|
15
|
+
from .jsonl import append_jsonl
|
|
15
16
|
from .run_context import task_mutex
|
|
16
17
|
|
|
17
18
|
|
|
@@ -104,8 +105,7 @@ class HostEventStreamWriter:
|
|
|
104
105
|
payload=payload,
|
|
105
106
|
timestamp=_utc_now(),
|
|
106
107
|
)
|
|
107
|
-
|
|
108
|
-
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
108
|
+
append_jsonl(self.path, row, ensure_ascii=False, compact=False)
|
|
109
109
|
return row
|
|
110
110
|
|
|
111
111
|
|
|
@@ -13,6 +13,7 @@ from .ids import build_run_id
|
|
|
13
13
|
from .paths import runs_dir_of
|
|
14
14
|
from .invocation import save_invocation
|
|
15
15
|
from .jsonl import append_jsonl, read_jsonl, rotate_recent_if_needed
|
|
16
|
+
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
16
17
|
from .project_meta import _project_meta_path
|
|
17
18
|
from .reconcile import _now_iso, normalize_central_status
|
|
18
19
|
from .run_index_row import build_run_index_row
|
|
@@ -64,8 +65,8 @@ def discover_project_roots(home: Path) -> List[tuple]:
|
|
|
64
65
|
if not meta_file.is_file():
|
|
65
66
|
continue
|
|
66
67
|
try:
|
|
67
|
-
meta =
|
|
68
|
-
except
|
|
68
|
+
meta = load_owned_object(meta_file, artifact="project metadata")
|
|
69
|
+
except JsonBoundaryError:
|
|
69
70
|
continue
|
|
70
71
|
pid = str(meta.get("projectId") or "")
|
|
71
72
|
root = str(meta.get("projectRoot") or "")
|
|
@@ -89,7 +90,7 @@ def _apply_backfill_meta(home: Path, project_id: str, project_root: Path, *,
|
|
|
89
90
|
target = _project_meta_path(home, project_id)
|
|
90
91
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
91
92
|
if target.is_file():
|
|
92
|
-
meta =
|
|
93
|
+
meta = load_owned_object(target, artifact="project metadata")
|
|
93
94
|
else:
|
|
94
95
|
meta = {"projectId": project_id, "projectRoot": str(project_root),
|
|
95
96
|
"firstRunAt": "", "lastRunAt": "",
|
|
@@ -108,9 +109,7 @@ def _apply_backfill_meta(home: Path, project_id: str, project_root: Path, *,
|
|
|
108
109
|
meta["runCount"] = int(meta.get("runCount", 0)) + run_count_inc
|
|
109
110
|
meta["activeCount"] = max(
|
|
110
111
|
0, int(meta.get("activeCount", 0)) + active_count_inc)
|
|
111
|
-
|
|
112
|
-
tmp.write_text(json.dumps(meta, indent=2) + "\n")
|
|
113
|
-
os.replace(tmp, target)
|
|
112
|
+
write_owned_object_atomic(target, meta, artifact="project metadata")
|
|
114
113
|
|
|
115
114
|
|
|
116
115
|
def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
|
|
@@ -142,8 +141,8 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
|
|
|
142
141
|
task_type = m.group("tt")
|
|
143
142
|
seq = int(m.group("seq"))
|
|
144
143
|
try:
|
|
145
|
-
manifest =
|
|
146
|
-
except
|
|
144
|
+
manifest = load_owned_object(mf, artifact="run manifest")
|
|
145
|
+
except JsonBoundaryError:
|
|
147
146
|
# 부분 기록 / 손상된 매니페스트를 빈 dict 로 강등해 row 를
|
|
148
147
|
# 만들면, 빈 status 가 'completed' 로 normalize 되어
|
|
149
148
|
# recent 에 박히고, 같은 runId 가 existing_ids 로 인덱싱
|
|
@@ -268,6 +267,10 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
|
|
|
268
267
|
|
|
269
268
|
def mark_backfilled(home: Path) -> None:
|
|
270
269
|
state_file = home / "state.json"
|
|
271
|
-
state =
|
|
270
|
+
state = (
|
|
271
|
+
load_owned_object(state_file, artifact="okstra home state")
|
|
272
|
+
if state_file.is_file()
|
|
273
|
+
else {}
|
|
274
|
+
)
|
|
272
275
|
state["backfilledAt"] = _now_iso()
|
|
273
|
-
state_file
|
|
276
|
+
write_owned_object_atomic(state_file, state, artifact="okstra home state")
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import List, Optional
|
|
10
10
|
|
|
11
11
|
from .listing import list_runs
|
|
12
|
+
from .json_boundary import write_owned_object_atomic
|
|
12
13
|
from .resolver import resolve_last, resolve_run_id
|
|
13
14
|
|
|
14
15
|
|
|
@@ -20,10 +21,7 @@ def make_batch_id() -> str:
|
|
|
20
21
|
|
|
21
22
|
def write_batch_meta(home: Path, batch_id: str, payload: dict) -> Path:
|
|
22
23
|
target = home / "batches" / f"{batch_id}.json"
|
|
23
|
-
target
|
|
24
|
-
tmp = target.with_suffix(".json.tmp")
|
|
25
|
-
tmp.write_text(json.dumps(payload, indent=2) + "\n")
|
|
26
|
-
os.replace(tmp, target)
|
|
24
|
+
write_owned_object_atomic(target, payload, artifact="batch metadata")
|
|
27
25
|
return target
|
|
28
26
|
|
|
29
27
|
|
|
@@ -16,6 +16,8 @@ import json
|
|
|
16
16
|
import shlex
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
20
|
+
|
|
19
21
|
DEFAULT_BUILD_TOOL_TOKENS: tuple[str, ...] = (
|
|
20
22
|
"npm",
|
|
21
23
|
"yarn",
|
|
@@ -49,10 +51,11 @@ def resolve_build_tool_tokens(project_root: Path) -> tuple[str, ...]:
|
|
|
49
51
|
uses, so one rule covers both.
|
|
50
52
|
"""
|
|
51
53
|
try:
|
|
52
|
-
payload =
|
|
53
|
-
|
|
54
|
+
payload = load_owned_object(
|
|
55
|
+
Path(project_root) / ".okstra" / "project.json",
|
|
56
|
+
artifact="project configuration",
|
|
54
57
|
)
|
|
55
|
-
except
|
|
58
|
+
except JsonBoundaryError:
|
|
56
59
|
return DEFAULT_BUILD_TOOL_TOKENS
|
|
57
60
|
configured = payload.get("buildToolTokens") if isinstance(payload, dict) else None
|
|
58
61
|
if not isinstance(configured, list):
|