okstra 0.220.1 → 0.222.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/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +6 -4
- package/docs/cli.md +13 -9
- package/docs/project-structure-overview.md +5 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
- package/runtime/prompts/lead/phase-routing.md +1 -1
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
- package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
- package/runtime/python/okstra_ctl/convergence.py +155 -19
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
- package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
- package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
- package/runtime/python/okstra_ctl/correction_cli.py +50 -11
- package/runtime/python/okstra_ctl/correction_round.py +80 -11
- package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
- package/runtime/python/okstra_ctl/domain/provider.py +2 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
- package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
- package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
- package/runtime/python/okstra_ctl/manager_cli.py +11 -1
- package/runtime/python/okstra_ctl/manager_sync.py +38 -2
- package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
- package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
- package/runtime/python/okstra_ctl/next_phase.py +22 -9
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
- package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
- package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
- package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
- package/runtime/python/okstra_ctl/process_group.py +4 -0
- package/runtime/python/okstra_ctl/render.py +7 -48
- package/runtime/python/okstra_ctl/report_assembly.py +50 -13
- package/runtime/python/okstra_ctl/report_html/common.py +3 -1
- package/runtime/python/okstra_ctl/report_language.py +4 -5
- package/runtime/python/okstra_ctl/report_projections.py +45 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
- package/runtime/python/okstra_ctl/run.py +36 -5
- package/runtime/python/okstra_ctl/stage_done.py +37 -3
- package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
- package/runtime/schemas/final-report-v3.0.schema.json +36 -0
- package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
- package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
- package/runtime/skills/okstra-discussion/SKILL.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +4 -3
- package/runtime/skills/okstra-setup/SKILL.md +2 -0
- package/runtime/skills/okstra-setup/references/project-config.md +34 -0
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/templates/reports/html/base.template.html +6 -1
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/reports/task-brief.template.md +3 -3
- package/runtime/validators/validate-run.py +18 -2
- package/runtime/validators/validate_session_conformance.py +19 -1
|
@@ -33,7 +33,13 @@ class WizardPrompt:
|
|
|
33
33
|
|
|
34
34
|
@dataclass(frozen=True)
|
|
35
35
|
class AnswerProtocol:
|
|
36
|
-
kind: Literal[
|
|
36
|
+
kind: Literal[
|
|
37
|
+
"exact-value",
|
|
38
|
+
"numbered",
|
|
39
|
+
"selected-labels",
|
|
40
|
+
"group-json",
|
|
41
|
+
"numbered-group-json",
|
|
42
|
+
]
|
|
37
43
|
multi: bool = False
|
|
38
44
|
|
|
39
45
|
|
|
@@ -120,6 +126,76 @@ def _normalize_numbered_answer(
|
|
|
120
126
|
)
|
|
121
127
|
|
|
122
128
|
|
|
129
|
+
_LABEL_SEPARATOR = ", "
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def labels_decode_uniquely(labels: tuple[str, ...]) -> bool:
|
|
133
|
+
"""이 라벨들을 `", "` 로 이은 어떤 선택이든 한 가지로만 되쪼개지는가.
|
|
134
|
+
|
|
135
|
+
네이티브 다중 선택 호스트는 고른 라벨을 `", "` 로 이어 돌려주는데 라벨 자체도
|
|
136
|
+
`", "` 를 품을 수 있다. 선택 문자열은 `label + ", "` 낱말의 연접이므로 그
|
|
137
|
+
낱말 집합에 대한 Sardinas-Patterson 유일 복호 판정이다.
|
|
138
|
+
"""
|
|
139
|
+
words = {label + _LABEL_SEPARATOR for label in labels}
|
|
140
|
+
if len(words) != len(labels):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def dangling(prefixes: set[str], strings: set[str]) -> set[str]:
|
|
144
|
+
return {
|
|
145
|
+
string[len(prefix):]
|
|
146
|
+
for prefix in prefixes
|
|
147
|
+
for string in strings
|
|
148
|
+
if string != prefix and string.startswith(prefix)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
seen: set[str] = set()
|
|
152
|
+
suffixes = dangling(words, words)
|
|
153
|
+
while suffixes:
|
|
154
|
+
if suffixes & words:
|
|
155
|
+
return False
|
|
156
|
+
seen |= suffixes
|
|
157
|
+
suffixes = (dangling(words, suffixes) | dangling(suffixes, words)) - seen
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalize_selected_labels(prompt: WizardPrompt, answer: str) -> str:
|
|
162
|
+
text = answer or ""
|
|
163
|
+
if not text.strip():
|
|
164
|
+
return ""
|
|
165
|
+
target = text + _LABEL_SEPARATOR
|
|
166
|
+
words = tuple(option.label + _LABEL_SEPARATOR for option in prompt.options)
|
|
167
|
+
# ways[i] 는 target[i:] 를 쪼개는 방법 수(2 에서 멈춤), first[i] 는 방법이
|
|
168
|
+
# 하나일 때 그 첫 옵션 위치다.
|
|
169
|
+
ways = [0] * len(target) + [1]
|
|
170
|
+
first = [-1] * (len(target) + 1)
|
|
171
|
+
for start in range(len(target) - 1, -1, -1):
|
|
172
|
+
for index, word in enumerate(words):
|
|
173
|
+
if target.startswith(word, start) and ways[start + len(word)]:
|
|
174
|
+
ways[start] = min(2, ways[start] + ways[start + len(word)])
|
|
175
|
+
first[start] = index
|
|
176
|
+
if ways[0] == 0:
|
|
177
|
+
values = {option.value for option in prompt.options}
|
|
178
|
+
# 라벨 대신 값 CSV 를 낸 제출(직접 CLI 입력, 값으로 바꿔 내는 리드)도 받는다.
|
|
179
|
+
if all(item.strip() in values for item in text.split(",")):
|
|
180
|
+
return ",".join(item.strip() for item in text.split(","))
|
|
181
|
+
raise WizardAnswerError(
|
|
182
|
+
f"multi-select answer is not a {_LABEL_SEPARATOR!r}-joined list "
|
|
183
|
+
f"of option labels: {text}"
|
|
184
|
+
)
|
|
185
|
+
if ways[0] > 1:
|
|
186
|
+
raise WizardAnswerError(f"multi-select answer labels are ambiguous: {text}")
|
|
187
|
+
selected: set[int] = set()
|
|
188
|
+
position = 0
|
|
189
|
+
while position < len(target):
|
|
190
|
+
selected.add(first[position])
|
|
191
|
+
position += len(words[first[position]])
|
|
192
|
+
return ",".join(
|
|
193
|
+
option.value
|
|
194
|
+
for index, option in enumerate(prompt.options)
|
|
195
|
+
if index in selected
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
123
199
|
def _group_answers(answer: str) -> dict[str, object]:
|
|
124
200
|
try:
|
|
125
201
|
answers = json.loads(answer or "{}")
|
|
@@ -137,19 +213,22 @@ def _normalize_group_answer(
|
|
|
137
213
|
numbered: bool,
|
|
138
214
|
) -> str:
|
|
139
215
|
answers = _group_answers(answer)
|
|
140
|
-
if not numbered:
|
|
141
|
-
return json.dumps(answers, ensure_ascii=False, separators=(",", ":"))
|
|
142
216
|
questions = {question.step: question for question in prompt.questions}
|
|
143
217
|
normalized = dict(answers)
|
|
144
218
|
for step, raw_answer in answers.items():
|
|
145
219
|
question = questions.get(step)
|
|
146
220
|
if question is None:
|
|
147
221
|
continue
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
222
|
+
if numbered:
|
|
223
|
+
normalized[step] = _normalize_numbered_answer(
|
|
224
|
+
question,
|
|
225
|
+
str(raw_answer or ""),
|
|
226
|
+
multi=question.multi,
|
|
227
|
+
)
|
|
228
|
+
elif question.multi:
|
|
229
|
+
normalized[step] = _normalize_selected_labels(
|
|
230
|
+
question, str(raw_answer or "")
|
|
231
|
+
)
|
|
153
232
|
return json.dumps(normalized, ensure_ascii=False, separators=(",", ":"))
|
|
154
233
|
|
|
155
234
|
|
|
@@ -162,6 +241,8 @@ def normalize_planned_answer(
|
|
|
162
241
|
return answer
|
|
163
242
|
if protocol.kind == "numbered":
|
|
164
243
|
return _normalize_numbered_answer(prompt, answer, multi=protocol.multi)
|
|
244
|
+
if protocol.kind == "selected-labels":
|
|
245
|
+
return _normalize_selected_labels(prompt, answer)
|
|
165
246
|
if protocol.kind in {"group-json", "numbered-group-json"}:
|
|
166
247
|
return _normalize_group_answer(
|
|
167
248
|
prompt,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""File-size thresholds for source and test files an implementation stage writes.
|
|
2
|
+
|
|
3
|
+
Every later agent that touches a file loads all of it into context, so a stage that
|
|
4
|
+
grows one file past a few hundred lines makes every following run pay for it. Both
|
|
5
|
+
thresholds are measured on the stage diff: 300 lines is the target, reported as an
|
|
6
|
+
advisory finding, and 400 lines is the limit, a blocking finding the correction round
|
|
7
|
+
fixes by splitting the file (`_implementation-deliverable.md` §"Correction round").
|
|
8
|
+
|
|
9
|
+
A line is a physical line, as `wc -l` and an editor's line numbers count it, plus an
|
|
10
|
+
unterminated last line. The cost being capped is what an agent reads, and comments and
|
|
11
|
+
blank lines are read too; it also lets the executor check the number with any tool.
|
|
12
|
+
|
|
13
|
+
Only source and test files are capped: the extensions in `self_mock_signals.EXT_TO_LANG`,
|
|
14
|
+
the languages okstra's coding preflight has rules for. Lockfiles, JSON / YAML data,
|
|
15
|
+
snapshots, Markdown, SQL and config fall outside by extension. Inside that set, vendored,
|
|
16
|
+
fixture, snapshot, generated and `.okstra` paths are exempt, and so is a file whose head
|
|
17
|
+
declares itself generated.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path, PurePosixPath
|
|
24
|
+
|
|
25
|
+
from .self_mock_signals import EXT_TO_LANG
|
|
26
|
+
|
|
27
|
+
FILE_LINE_TARGET = 300
|
|
28
|
+
FILE_LINE_LIMIT = 400
|
|
29
|
+
|
|
30
|
+
_EXEMPT_SEGMENTS = frozenset({
|
|
31
|
+
".okstra", "node_modules", "vendor", "fixtures", "__fixtures__", "__snapshots__",
|
|
32
|
+
"generated", "__generated__",
|
|
33
|
+
})
|
|
34
|
+
_GENERATED_MARKERS = (b"@generated", b"do not edit")
|
|
35
|
+
_MARKER_HEAD_LINES = 5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class FileSizeViolation:
|
|
40
|
+
path: str
|
|
41
|
+
before: int | None
|
|
42
|
+
after: int
|
|
43
|
+
kind: str
|
|
44
|
+
threshold: int
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def blocking(self) -> bool:
|
|
48
|
+
return self.threshold == FILE_LINE_LIMIT
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def finding_id(self) -> str:
|
|
52
|
+
prefix = "FILE-SIZE" if self.blocking else "FILE-SIZE-ADVISORY"
|
|
53
|
+
return f"{prefix}:{self.path}"
|
|
54
|
+
|
|
55
|
+
def line(self) -> str:
|
|
56
|
+
before = "new" if self.before is None else str(self.before)
|
|
57
|
+
label = "limit" if self.blocking else "target"
|
|
58
|
+
return (
|
|
59
|
+
f"{self.finding_id}:{self.threshold + 1} {before}->{self.after} lines "
|
|
60
|
+
f"({self.kind}; {label} {self.threshold})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def capped_path(path: str) -> bool:
|
|
65
|
+
"""True when the cap applies to this repository-relative path."""
|
|
66
|
+
pure = PurePosixPath(path)
|
|
67
|
+
if EXT_TO_LANG.get(pure.suffix) is None or ".min." in pure.name:
|
|
68
|
+
return False
|
|
69
|
+
return not any(part in _EXEMPT_SEGMENTS for part in pure.parts[:-1])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def physical_lines(data: bytes) -> int:
|
|
73
|
+
return data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _declares_generated(data: bytes) -> bool:
|
|
77
|
+
head = b"\n".join(data.split(b"\n", _MARKER_HEAD_LINES)[:_MARKER_HEAD_LINES]).lower()
|
|
78
|
+
return any(marker in head for marker in _GENERATED_MARKERS)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git(repo: Path, *args: str) -> bytes:
|
|
82
|
+
return subprocess.run(
|
|
83
|
+
["git", "-C", str(repo), *args], check=True, capture_output=True,
|
|
84
|
+
).stdout
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _blob(repo: Path, rev: str, path: str) -> bytes:
|
|
88
|
+
return _git(repo, "show", f"{rev}:{path}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _changed(repo: Path, base: str, head: str) -> list[tuple[str, str | None, str]]:
|
|
92
|
+
"""(status letter, path at base or None, path at head) for each added or modified file."""
|
|
93
|
+
fields = _git(repo, "diff", "--name-status", "-z", "-M", f"{base}..{head}").split(b"\0")
|
|
94
|
+
rows: list[tuple[str, str | None, str]] = []
|
|
95
|
+
index = 0
|
|
96
|
+
while index < len(fields) and fields[index]:
|
|
97
|
+
status = fields[index].decode()[0]
|
|
98
|
+
if status == "R":
|
|
99
|
+
rows.append((status, fields[index + 1].decode(), fields[index + 2].decode()))
|
|
100
|
+
index += 3
|
|
101
|
+
continue
|
|
102
|
+
path = fields[index + 1].decode()
|
|
103
|
+
if status in "AM":
|
|
104
|
+
rows.append((status, path if status == "M" else None, path))
|
|
105
|
+
index += 2
|
|
106
|
+
return rows
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _breach(before: int | None, after: int, threshold: int) -> str:
|
|
110
|
+
"""Which way the diff breaks `threshold`, or '' when it does not."""
|
|
111
|
+
if after <= threshold:
|
|
112
|
+
return ""
|
|
113
|
+
if before is None:
|
|
114
|
+
return f"new file over {threshold}"
|
|
115
|
+
if before <= threshold:
|
|
116
|
+
return f"crossed {threshold}"
|
|
117
|
+
return f"grew a file over {threshold}" if after > before else ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def file_size_violations(repo: Path, base: str, head: str = "HEAD") -> list[FileSizeViolation]:
|
|
121
|
+
"""Files the `base..head` diff leaves over a threshold, in diff order.
|
|
122
|
+
|
|
123
|
+
A new file over a threshold, an existing file the diff takes over it, and a file
|
|
124
|
+
already over it that the diff grows. A file already over it that the diff leaves the
|
|
125
|
+
same size or shrinks is not this stage's to split. A file that breaks the limit is
|
|
126
|
+
reported once, as blocking; one that breaks only the target is advisory.
|
|
127
|
+
"""
|
|
128
|
+
violations: list[FileSizeViolation] = []
|
|
129
|
+
for _status, old_path, path in _changed(repo, base, head):
|
|
130
|
+
if not capped_path(path):
|
|
131
|
+
continue
|
|
132
|
+
data = _blob(repo, head, path)
|
|
133
|
+
after = physical_lines(data)
|
|
134
|
+
if after <= FILE_LINE_TARGET or _declares_generated(data):
|
|
135
|
+
continue
|
|
136
|
+
before = None if old_path is None else physical_lines(_blob(repo, base, old_path))
|
|
137
|
+
for threshold in (FILE_LINE_LIMIT, FILE_LINE_TARGET):
|
|
138
|
+
kind = _breach(before, after, threshold)
|
|
139
|
+
if kind:
|
|
140
|
+
violations.append(FileSizeViolation(path, before, after, kind, threshold))
|
|
141
|
+
break
|
|
142
|
+
return violations
|
|
@@ -17,7 +17,9 @@ from __future__ import annotations
|
|
|
17
17
|
import json
|
|
18
18
|
import re
|
|
19
19
|
from pathlib import Path
|
|
20
|
-
from typing import Any, Dict, List, Optional
|
|
20
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
21
|
+
|
|
22
|
+
from okstra_project.phase_pointer import promote
|
|
21
23
|
|
|
22
24
|
from .run_context import dir_flock
|
|
23
25
|
from .jsonl import append_jsonl
|
|
@@ -33,6 +35,17 @@ FIX_CYCLE_ENTRY_PHASES = (
|
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def delivery_completed(workflow: Mapping[str, Any]) -> bool:
|
|
39
|
+
"""release-handoff 가 전달까지 끝냈는가. fix cycle 을 열고 닫는 조건이다.
|
|
40
|
+
|
|
41
|
+
skip·미전달 stage 가 남은 handoff 는 다음 phase 포인터를 다시
|
|
42
|
+
release-handoff 로 둔다(`next_phase.project`).
|
|
43
|
+
"""
|
|
44
|
+
if workflow.get("lastCompletedPhase") != "release-handoff":
|
|
45
|
+
return False
|
|
46
|
+
return promote(workflow.get("nextRecommendedPhase"))["phase"] != "release-handoff"
|
|
47
|
+
|
|
48
|
+
|
|
36
49
|
def fix_cycles_path(task_root: Path) -> Path:
|
|
37
50
|
return Path(task_root) / "history" / FIX_CYCLES_FILENAME
|
|
38
51
|
|
|
@@ -165,13 +165,20 @@ class ReconcileError(Exception):
|
|
|
165
165
|
pass
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
# 최신 done 행이 last-wins 라, 보정 행이 이 필드를 안 옮기면 stage 의 보고서·carry 가 사라진다.
|
|
169
|
+
_COMPLETION_FIELDS = ("report_path", "carry_path", "completed_at")
|
|
170
|
+
|
|
171
|
+
|
|
168
172
|
def _record_reconciled(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
169
173
|
new_commit: str, replaced: str, reason: str) -> None:
|
|
170
|
-
from .consumers import append_consumer
|
|
174
|
+
from .consumers import append_consumer, latest_done_by_stage, read_consumers
|
|
175
|
+
previous = latest_done_by_stage(read_consumers(plan_run_root)).get(stage) or {}
|
|
176
|
+
completion = {k: previous[k] for k in _COMPLETION_FIELDS if previous.get(k)}
|
|
171
177
|
append_consumer(
|
|
172
178
|
plan_run_root, impl_task_key=impl_task_key, stage=stage,
|
|
173
179
|
status="done", force_reappend=True, head_commit=new_commit,
|
|
174
180
|
reconciled=True, reconcile_reason=reason, replaced_commit=replaced,
|
|
181
|
+
**completion,
|
|
175
182
|
)
|
|
176
183
|
|
|
177
184
|
|
|
@@ -195,12 +202,19 @@ def auto_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
195
202
|
return applied
|
|
196
203
|
|
|
197
204
|
|
|
198
|
-
def
|
|
205
|
+
def _command(*, plan_run_root: Path, project_id: str, task_group: str,
|
|
199
206
|
task_id: str, work_category: str) -> str:
|
|
200
|
-
"
|
|
201
|
-
base = (f"okstra git-reconcile --plan-run-root {plan_run_root} "
|
|
207
|
+
return (f"okstra git-reconcile --plan-run-root {plan_run_root} "
|
|
202
208
|
f"--project-id {project_id} --task-group {task_group} "
|
|
203
209
|
f"--task-id {task_id} --work-category {work_category}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def guidance(*, plan_run_root: Path, project_id: str, task_group: str,
|
|
213
|
+
task_id: str, work_category: str) -> str:
|
|
214
|
+
"""PrepareError 에 첨부하는 회복 안내 (명령 예시 포함)."""
|
|
215
|
+
base = _command(plan_run_root=plan_run_root, project_id=project_id,
|
|
216
|
+
task_group=task_group, task_id=task_id,
|
|
217
|
+
work_category=work_category)
|
|
204
218
|
return ("Recorded stage SHAs no longer match the git history "
|
|
205
219
|
"(external rebase/squash/amend?). Inspect and reconcile:\n"
|
|
206
220
|
f" {base} --check --text\n"
|
|
@@ -224,6 +238,32 @@ def _apply_user_ref(project_root: Path, plan_run_root: Path,
|
|
|
224
238
|
return {"stage": stage, "new_commit": sha}
|
|
225
239
|
|
|
226
240
|
|
|
241
|
+
def _anchor_warning(*, project_root: Path, plan_run_root: Path, project_id: str,
|
|
242
|
+
task_group: str, task_id: str, work_category: str,
|
|
243
|
+
stage: int, new_commit: str) -> str:
|
|
244
|
+
"""보정한 head 가 anchor 를 담지 않으면 whole-task diff 의 base 가 틀린다.
|
|
245
|
+
anchor 는 --reset-anchor 로만 옮기므로 경고와 명령만 낸다."""
|
|
246
|
+
from . import worktree_registry as _reg
|
|
247
|
+
|
|
248
|
+
anchor = _reg.get_implementation_base(project_id, task_group, task_id) or ""
|
|
249
|
+
if not _resolve_commit_sha(project_root, anchor):
|
|
250
|
+
return ""
|
|
251
|
+
if _git(project_root, "merge-base", "--is-ancestor",
|
|
252
|
+
anchor, new_commit).returncode == 0:
|
|
253
|
+
return ""
|
|
254
|
+
entry = _reg.lookup(project_id, task_group, task_id)
|
|
255
|
+
base_ref = entry.base_ref if entry else ""
|
|
256
|
+
new_base = (_git(project_root, "merge-base", base_ref, new_commit).stdout.strip()
|
|
257
|
+
if base_ref else "")
|
|
258
|
+
command = _command(plan_run_root=plan_run_root, project_id=project_id,
|
|
259
|
+
task_group=task_group, task_id=task_id,
|
|
260
|
+
work_category=work_category)
|
|
261
|
+
return (f"stage {stage} head {new_commit} does not contain the implementation "
|
|
262
|
+
f"anchor {anchor}; whole-task final-verification would diff from the "
|
|
263
|
+
f"old base. Move the anchor after confirming the new base:\n"
|
|
264
|
+
f" {command} --apply --reset-anchor {new_base or '<new-base-commit>'}")
|
|
265
|
+
|
|
266
|
+
|
|
227
267
|
def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
228
268
|
project_id: str, task_group: str, task_id: str,
|
|
229
269
|
work_category: str, stage: Optional[int] = None,
|
|
@@ -239,6 +279,7 @@ def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
239
279
|
from .consumers import read_consumers, latest_done_by_stage
|
|
240
280
|
|
|
241
281
|
applied: List[dict] = []
|
|
282
|
+
warnings: List[str] = []
|
|
242
283
|
if reset_anchor:
|
|
243
284
|
sha = _resolve_commit_sha(project_root, reset_anchor)
|
|
244
285
|
if not sha:
|
|
@@ -247,8 +288,16 @@ def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
247
288
|
applied.append({"anchor": sha})
|
|
248
289
|
if use_ref:
|
|
249
290
|
latest = latest_done_by_stage(read_consumers(plan_run_root))
|
|
250
|
-
|
|
251
|
-
project_root, plan_run_root, latest, stage, use_ref)
|
|
291
|
+
recorded = _apply_user_ref(
|
|
292
|
+
project_root, plan_run_root, latest, stage, use_ref)
|
|
293
|
+
applied.append(recorded)
|
|
294
|
+
warning = _anchor_warning(
|
|
295
|
+
project_root=project_root, plan_run_root=plan_run_root,
|
|
296
|
+
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
297
|
+
work_category=work_category, stage=stage,
|
|
298
|
+
new_commit=recorded["new_commit"])
|
|
299
|
+
if warning:
|
|
300
|
+
warnings.append(warning)
|
|
252
301
|
items = classify_task(
|
|
253
302
|
project_root=project_root, plan_run_root=plan_run_root,
|
|
254
303
|
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
@@ -264,7 +313,8 @@ def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
264
313
|
applied.append({"stage": item.stage, "new_commit": item.suggested_commit})
|
|
265
314
|
remaining = [i for i in items if i.classification == "confirm"
|
|
266
315
|
and not (use_ref and i.stage == stage)]
|
|
267
|
-
return {"applied": applied, "remaining_confirm": remaining
|
|
316
|
+
return {"applied": applied, "remaining_confirm": remaining,
|
|
317
|
+
"warnings": warnings}
|
|
268
318
|
|
|
269
319
|
|
|
270
320
|
def _items_as_json(items: List[StaleItem]) -> list:
|
|
@@ -345,6 +395,9 @@ def render_git_reconcile_text(payload: dict) -> str:
|
|
|
345
395
|
rows.append(line(f"Item {index} kind", item.get("kind")))
|
|
346
396
|
rows.append(line(f"Item {index} classification", item.get("classification")))
|
|
347
397
|
rows.append(line(f"Item {index} reason", item.get("reason")))
|
|
398
|
+
warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else []
|
|
399
|
+
for index, warning in enumerate(warnings, 1):
|
|
400
|
+
rows.append(line(f"Warning {index}", warning))
|
|
348
401
|
if "error" in payload:
|
|
349
402
|
rows.append(line("Failure reason", payload.get("error")))
|
|
350
403
|
return "".join(rows)
|
|
@@ -362,7 +415,8 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
362
415
|
result = apply_reconcile(**kw, stage=a.stage, use_ref=a.use_ref,
|
|
363
416
|
reset_anchor=a.reset_anchor)
|
|
364
417
|
out = {"applied": result["applied"],
|
|
365
|
-
"remaining_confirm": _items_as_json(result["remaining_confirm"])
|
|
418
|
+
"remaining_confirm": _items_as_json(result["remaining_confirm"]),
|
|
419
|
+
"warnings": result["warnings"]}
|
|
366
420
|
_emit_result(out, as_json=a.json, as_text=a.text)
|
|
367
421
|
return 2 if result["remaining_confirm"] else 0
|
|
368
422
|
items = classify_task(**kw)
|
|
@@ -25,7 +25,6 @@ from .agent.invocation import (
|
|
|
25
25
|
verify_agent_invocation,
|
|
26
26
|
)
|
|
27
27
|
from .path_hints import hydrate_active_run_context
|
|
28
|
-
from .report_language import resolve_report_language
|
|
29
28
|
from .report_inputs import report_narrative_path, uses_report_contract_v3
|
|
30
29
|
from .report_synthesis_packet import (
|
|
31
30
|
ReportSynthesisPacketError,
|
|
@@ -708,11 +707,9 @@ def _prompt_body(
|
|
|
708
707
|
context.active_context,
|
|
709
708
|
context.team_state,
|
|
710
709
|
worker.model,
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
context.active_context,
|
|
715
|
-
),
|
|
710
|
+
# 조립이 `meta.reportLanguage` 로 옮기는 매니페스트 값. 설정을 다시 읽으면
|
|
711
|
+
# prepare 뒤의 설정 변경이 프롬프트와 리포트의 언어를 가른다.
|
|
712
|
+
str(context.manifest.get("reportLanguage") or "en"),
|
|
716
713
|
synthesis_packet_path,
|
|
717
714
|
)
|
|
718
715
|
return analysis_prompt_body(
|
|
@@ -139,6 +139,11 @@ def _render_list(command: str, items: list[dict]) -> list[str]:
|
|
|
139
139
|
return rows
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
def _age_text(seconds: int) -> str:
|
|
143
|
+
hours, rest = divmod(int(seconds), 3600)
|
|
144
|
+
return f"{hours}h {rest // 60}m"
|
|
145
|
+
|
|
146
|
+
|
|
142
147
|
def _render_manager_status(payload: dict) -> list[str]:
|
|
143
148
|
rows: list[str] = []
|
|
144
149
|
manifest = payload.get("manifest") if isinstance(payload.get("manifest"), dict) else {}
|
|
@@ -148,6 +153,10 @@ def _render_manager_status(payload: dict) -> list[str]:
|
|
|
148
153
|
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
149
154
|
for key in ("planned", "missing", "running", "done", "blocked", "discussion"):
|
|
150
155
|
rows.append(line(f"Summary {key}", summary.get(key)))
|
|
156
|
+
rows.append(line("Synced at", payload.get("syncedAt") or "never"))
|
|
157
|
+
if "syncAgeSeconds" in payload:
|
|
158
|
+
rows.append(line("Sync age", _age_text(payload["syncAgeSeconds"])))
|
|
159
|
+
rows.append(line("Changed since sync count", payload.get("changedSinceSyncCount", 0)))
|
|
151
160
|
children = payload.get("children") if isinstance(payload.get("children"), list) else []
|
|
152
161
|
for index, child in enumerate(children, 1):
|
|
153
162
|
if not isinstance(child, dict):
|
|
@@ -156,7 +165,8 @@ def _render_manager_status(payload: dict) -> list[str]:
|
|
|
156
165
|
("current phase", "currentPhase"), ("work status", "workStatus"),
|
|
157
166
|
("summary", "summaryBucket"), ("project linked", "projectLinked"),
|
|
158
167
|
("ticket", "ticketId"), ("brief path", "briefPath"),
|
|
159
|
-
("report path", "latestReportRecordPath"),
|
|
168
|
+
("report path", "latestReportRecordPath"),
|
|
169
|
+
("changed since sync", "changedSinceSync"), ("error", "error")):
|
|
160
170
|
if key in child:
|
|
161
171
|
value = child.get(key)
|
|
162
172
|
rows.append(line(f"Child {index} {label}", ("yes" if value else "no") if isinstance(value, bool) else value))
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Read project-local okstra state into manager-owned snapshots."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from okstra_project import TASKS_RELATIVE, StateError, parse_task_key, read_task_catalog, read_task_manifest, slugify
|
|
8
|
+
from okstra_project.dirs import TASK_MANIFEST_FILENAME
|
|
7
9
|
|
|
8
10
|
from . import next_phase
|
|
9
11
|
from .next_phase import STATUS_BLOCKED, STATUS_TERMINAL
|
|
@@ -168,12 +170,35 @@ def summary_bucket(row: dict) -> str:
|
|
|
168
170
|
return _POINTER_STATUS_BUCKETS.get(pointer_status, "running")
|
|
169
171
|
|
|
170
172
|
|
|
171
|
-
def
|
|
173
|
+
def _epoch(value: str) -> int | None:
|
|
174
|
+
if not value:
|
|
175
|
+
return None
|
|
176
|
+
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _manifest_changed_since(project: dict | None, task_key: str, synced: int | None) -> bool:
|
|
180
|
+
"""프로젝트의 task-manifest 가 마지막 sync 뒤에 바뀌었는지 — 파일 시각만 본다."""
|
|
181
|
+
if project is None or not task_key:
|
|
182
|
+
return False
|
|
183
|
+
root = Path(str(project.get("projectRoot") or ""))
|
|
184
|
+
if not root.is_dir():
|
|
185
|
+
return False
|
|
186
|
+
task_dir = _resolve_task_root_read_only(root, task_key)
|
|
187
|
+
manifest_path = task_dir / TASK_MANIFEST_FILENAME if task_dir is not None else None
|
|
188
|
+
if manifest_path is None or not manifest_path.is_file():
|
|
189
|
+
return False
|
|
190
|
+
# syncedAt 은 초 단위라 mtime 도 초로 내려 같은 초에 쓴 파일을 변경으로 세지 않는다.
|
|
191
|
+
return synced is None or int(manifest_path.stat().st_mtime) > synced
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def status_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now: str | None = None) -> dict:
|
|
172
195
|
manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
173
196
|
children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
174
197
|
snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
175
198
|
by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
|
|
176
199
|
linked = _project_map(home, manager_id)
|
|
200
|
+
synced_at = str(snapshot.get("syncedAt") or "")
|
|
201
|
+
synced = _epoch(synced_at)
|
|
177
202
|
rows = []
|
|
178
203
|
summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0, "discussion": 0}
|
|
179
204
|
for child in children.get("children", []):
|
|
@@ -184,6 +209,17 @@ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> d
|
|
|
184
209
|
row["summaryBucket"] = "planned" if snapshot_row is None else summary_bucket(row)
|
|
185
210
|
# `remove project` 는 하위 태스크를 남긴다. 등록 목록에 없으면 해제된 프로젝트의 것이다.
|
|
186
211
|
row["projectLinked"] = str(child.get("projectId") or "") in linked
|
|
212
|
+
try:
|
|
213
|
+
row["changedSinceSync"] = _manifest_changed_since(
|
|
214
|
+
linked.get(str(child.get("projectId") or "")), str(child.get("taskKey") or ""), synced)
|
|
215
|
+
except StateError as exc:
|
|
216
|
+
row["changedSinceSync"] = True
|
|
217
|
+
row["error"] = str(exc)
|
|
187
218
|
summary[row["summaryBucket"]] += 1
|
|
188
219
|
rows.append(row)
|
|
189
|
-
|
|
220
|
+
payload = {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot,
|
|
221
|
+
"syncedAt": synced_at,
|
|
222
|
+
"changedSinceSyncCount": sum(1 for row in rows if row["changedSinceSync"])}
|
|
223
|
+
if synced is not None:
|
|
224
|
+
payload["syncAgeSeconds"] = max(0, (_epoch(now or _now_iso()) or 0) - synced)
|
|
225
|
+
return payload
|