okstra 0.170.0 → 0.170.1
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/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/plan-body-verification.md +2 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +32 -3
- package/runtime/python/okstra_ctl/plan_items_cli.py +17 -1
- package/runtime/schemas/final-report-v2.0.schema.json +5 -0
- package/runtime/validators/validate-run.py +71 -0
package/docs/cli.md
CHANGED
|
@@ -756,7 +756,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
|
|
|
756
756
|
| `okstra plan-items derivations --data <data.json> --response <user-response sidecar> [--clarification C-NNN]` | List the plan statements an answered clarification may have falsified. Extracts the symbols, paths, and ids the answer names (backticked spans plus `R-001` / `DEV-10174`-style ids) and reports every string in the plan body that mentions one, as a JSON pointer plus excerpt. Advisory: it says where a decision's subject is mentioned, never which mentions are now wrong — the supersession rule (`prompts/profiles/_common-contract.md`) requires the author to enumerate before editing, and this supplies the enumeration |
|
|
757
757
|
| `okstra plan-items seed --data <data.json>` | Create the `implementationPlanning.planBodyVerification.planItems[]` row every verdict lands in, from the same deterministic extraction `extract` uses. `apply-verdicts` refuses a verdict whose item has no row, and the report writer leaves the array empty, so this is the step between them. Idempotent by id: an existing row keeps its verdicts and carried fields, so it is safe to re-run between rounds. Reports `seeded` / `existing` counts |
|
|
758
758
|
| `okstra plan-items collect-verdicts --result <worker-id>=<path>… --items <items.json> --output <verdicts.json>` | Read one plan-verify round's worker responses into a verdicts envelope, using the `### <item-id>` / `**Verdict**` block shape fixed by `prompts/lead/plan-body-verification.md`. Exits 2 on an assigned item the worker left unanswered, a verdict for an item outside the persisted queue, a `DISAGREE` with no breakage kind, or a `DISAGREE` without a fixability value — every failure mode a per-round regex drops silently |
|
|
759
|
-
| `okstra plan-items apply-verdicts --data <data.json> --verdicts <verdicts.json>` | Overwrite `implementationPlanning.planBodyVerification.planItems[].verdicts` with that envelope. Overwrites rather than merges, because the contract records one round at a time and a merged table lets the previous round's votes keep voting. Exits 2 when the report carries no row for an incoming item |
|
|
759
|
+
| `okstra plan-items apply-verdicts --data <data.json> --verdicts <verdicts.json> --round <N>` | Overwrite `implementationPlanning.planBodyVerification.planItems[].verdicts` with that envelope. Overwrites rather than merges, because the contract records one round at a time and a merged table lets the previous round's votes keep voting. Exits 2 when the report carries no row for an incoming item. `--round` stamps the verification round on every written row: a self-fix round rewrites the plan after a verification round, so an item left out of a later round keeps a verdict on text that has changed. `validate-run.py` fails a gate resting on any verdict whose round is at or before `selfFixRoundsApplied`. |
|
|
760
760
|
| `okstra config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | Manage persistent settings such as `pr-template-path` with atomic JSON writes |
|
|
761
761
|
| `okstra memory <add\|list\|search\|show\|archive>` | Manage global conversation memory in `~/.okstra/memory-book`, a user-home store separate from project `.okstra/` and the CLI basis of the `save this in okstra` natural-language skill |
|
|
762
762
|
| `okstra manager <init\|discover-projects\|new\|task>` | Public CLI for grouping cross-project okstra tasks into manager-owned context. `new project`, `new task-group`, and `new task` create manager plans; `task assign`, `task note`, `task sync`, `task status`, and `task run` manage per-project assignments and snapshots. `new project --project-root` accepts only existing directories and performs setup-equivalent registration only if `.okstra/project.json` is absent. Public documentation uses the full `project-id:task-group:task-id` child task key; when child task IDs differ within the same manager task, select the exact child with `--child-task-id`. `task run` does not execute the child lead directly; it returns `prepared` launch metadata/event and a child launch-context packet as JSON |
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -243,7 +243,8 @@ round before any host or provider process starts.
|
|
|
243
243
|
- lead instructs report-writer to rewrite the items in each cause group (NOT a full draft regeneration; procedure in [report-writer](./report-writer.md) §"Self-fix rewrite").
|
|
244
244
|
- missing or weak `P-Prep-*` contracts are repaired by adding kind-specific inline detail or an AI-prepared PREP item with a concrete proposal. Facts that require user or external authority remain `blocked` and keep their request material; never invent those facts during self-fix.
|
|
245
245
|
- **Drop plan items whose element the round deleted.** A self-fix rewrite may remove a plan element (a validation check, a rollback row). `P-*` ids are positional, so a deletion shifts every later row and silently re-points surviving verdicts at their neighbours — and a verdict recorded against a removed element keeps blocking a gate while being unfindable in the plan, so reading the plan never reveals the cause. After each round, re-extract plan items with `okstra plan-items extract` and re-verify any item whose `subject` no longer matches; never carry the old vote forward across a shift. **Enforced:** `validators/validate-run.py` `_validate_verdicts_match_current_subjects` (re-pointing) and `_validate_plan_item_extraction_completeness` (dangling ids).
|
|
246
|
-
-
|
|
246
|
+
- **A verdict older than the last self-fix is not a verdict (BLOCKING).** Rounds interleave with rewrites — round 1, self-fix 1, round 2, self-fix 2 — so a verdict cast in round R judged the text as it stood after self-fix R-1. Once self-fix R runs, that judgement is about a plan that no longer exists. `--round <N>` on `apply-verdicts` stamps each row, and `validators/validate-run.py` `_validate_verdict_rounds_outlive_self_fix` fails any non-carried item whose verdict round is at or before `selfFixRoundsApplied`. This is why "adjacent items the rewrite touched" is not sufficient on its own: adjacency is judged from `subject` changes, and the observed failure was items whose own subject never moved while the stage they point at was rewritten under them. On one run the gate read `passed-with-dissent` with zero blockers and a single re-run flipped 3 of 27 items to `majority-disagree`, all correctness-critical. Before declaring the gate, every item still holding a pre-self-fix verdict MUST be re-verified in a round after the last rewrite.
|
|
247
|
+
- lead re-runs plan-body verification (focused on the corrected items + adjacent items the rewrite touched, plus any `needs-reverify` items whose peer failed to vote last round). After re-verification, overwrite `planItems[].verdicts` with the new verdicts. **The round's verdicts MUST be transcribed into `planBodyVerification.planItems[].verdicts` in the final report's data.json before the gate is declared** — the gate is re-derived from that table, so declaring a gate over an empty one leaves it unauditable. **Enforced:** `_validate_round_recorded_verdicts`. Transcribe with `okstra plan-items collect-verdicts --result <worker>=<path> … --items <plan-items.json> --output <verdicts.json>` then `okstra plan-items apply-verdicts --data <data.json> --verdicts <verdicts.json> --round <N>`, never with a per-round script: the CLI reads the response shape this section fixes and **fails** on an assigned item the worker left unanswered, on a verdict for an item outside the queue, and on a `DISAGREE` with no breakage kind. A hand-written regex reports none of those — it drops them, and the round is then scored on a table that silently does not match the queue.
|
|
247
248
|
- for an item whose `majority-disagree` was resolved by self-fix, record `self-fixed in round <N>: <what was fixed>` in `planItems[].selfFixNote`. A resolved item does not create a clarification.
|
|
248
249
|
- **Each round is a worker batch.** Before dispatching round N ≥ 2, reclaim the previous round's completed verifiers exactly as at any other batch boundary ([okstra-lead-contract](./okstra-lead-contract.md) "Run-scoped worker-resource lifecycle") and emit `PROGRESS: phase-batch-cleanup panes=<n>`, then announce the round with `PROGRESS: phase-5.5.9-plan-verify round=<N> items=<count>`. Saying a round will "reuse" the previous verifiers and then dispatching under fresh names leaves every prior round holding its panes — five rounds of that is what exhausts the pane budget and blocks the next dispatch. **Enforced:** `validators/validate_session_conformance.py` `_check_plan_verify_cleanup_checkpoints` requires both lines once the state file records two or more rounds.
|
|
249
250
|
- **Round completion.** A round is complete only after the renderer has run on the corrected data.json, lead has appended the round to the state file per step 6, lead has reconciled instructed groups against applied corrections — every `itemIds` entry either carries a `selfFixNote` or is still recorded as broken — and **`okstra plan-verify --report <report>` exits 0** (step 5). A round left with a non-zero exit carries its defect into the next round's inputs, which is how a mis-scored gate survives a whole self-fix budget. A round that was instructed but never rendered has not happened, and counting it inflates the budget that gates promotion. The state-file append is not optional bookkeeping: the next re-verification overwrites data.json's `planItems[].verdicts`, so a round that never reached `roundHistory[]` leaves no record anywhere of what it blocked on — which is the whole reason this file exists. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_rounds` requires one `roundHistory[]` entry per round `1..roundCount`, each carrying its own `gateResult` and cited by at least one item's `rounds[]`, and requires the file's `selfFixRoundsApplied` to match the report's.
|
|
@@ -935,7 +935,7 @@ def _publish_or_reuse(
|
|
|
935
935
|
if metadata_exists and not prompt_exists:
|
|
936
936
|
raise _existing_conflict("metadata exists without prompt")
|
|
937
937
|
if prompt_exists and prompt.read_bytes() != materialized.prompt_bytes:
|
|
938
|
-
_replace_undispatched(request, materialized)
|
|
938
|
+
_replace_undispatched(request, materialized, _prompt_difference(prompt, materialized))
|
|
939
939
|
return
|
|
940
940
|
if prompt_exists and metadata_exists:
|
|
941
941
|
if metadata.read_bytes() != materialized.metadata_bytes:
|
|
@@ -946,8 +946,37 @@ def _publish_or_reuse(
|
|
|
946
946
|
_publish_exclusive(metadata, materialized.metadata_bytes)
|
|
947
947
|
|
|
948
948
|
|
|
949
|
+
def _prompt_difference(prompt: Path, materialized: _MaterializedInvocation) -> str:
|
|
950
|
+
"""What actually differs between the published prompt and this one.
|
|
951
|
+
|
|
952
|
+
"existing prompt differs" alone cannot be acted on: it does not say whether
|
|
953
|
+
the file on disk is a stale sibling, a partially written artifact, or the
|
|
954
|
+
same prompt built from an edited instruction file — and the evidence is
|
|
955
|
+
destroyed by the very republish that unblocks the caller. One real
|
|
956
|
+
investigation ended undecided for exactly this reason. Digests identify the
|
|
957
|
+
two bodies, and the first differing line points at the edit.
|
|
958
|
+
"""
|
|
959
|
+
try:
|
|
960
|
+
existing = prompt.read_bytes()
|
|
961
|
+
except OSError as exc:
|
|
962
|
+
return f"existing prompt is unreadable: {exc}"
|
|
963
|
+
composed = materialized.prompt_bytes
|
|
964
|
+
detail = (
|
|
965
|
+
f"existing {_sha256(existing)}, composed {_sha256(composed)}"
|
|
966
|
+
)
|
|
967
|
+
existing_lines = existing.decode("utf-8", errors="replace").splitlines()
|
|
968
|
+
composed_lines = composed.decode("utf-8", errors="replace").splitlines()
|
|
969
|
+
for index, (left, right) in enumerate(zip(existing_lines, composed_lines), start=1):
|
|
970
|
+
if left != right:
|
|
971
|
+
return f"{detail}, first differing line {index}"
|
|
972
|
+
shorter = min(len(existing_lines), len(composed_lines))
|
|
973
|
+
return f"{detail}, identical through line {shorter} then one body continues"
|
|
974
|
+
|
|
975
|
+
|
|
949
976
|
def _replace_undispatched(
|
|
950
|
-
request: AgentInvocationRequest,
|
|
977
|
+
request: AgentInvocationRequest,
|
|
978
|
+
materialized: _MaterializedInvocation,
|
|
979
|
+
difference: str,
|
|
951
980
|
) -> None:
|
|
952
981
|
"""Rewrite a prompt this invocation id wrote but no dispatch ever used.
|
|
953
982
|
|
|
@@ -963,7 +992,7 @@ def _replace_undispatched(
|
|
|
963
992
|
agent — puts the prompt back under the original rule.
|
|
964
993
|
"""
|
|
965
994
|
if not request.replace_undispatched:
|
|
966
|
-
raise _existing_conflict("existing prompt differs")
|
|
995
|
+
raise _existing_conflict(f"existing prompt differs ({difference})")
|
|
967
996
|
dispatched = _recorded_dispatch_ids(request)
|
|
968
997
|
if dispatched:
|
|
969
998
|
raise _existing_conflict(
|
|
@@ -91,6 +91,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
91
91
|
)
|
|
92
92
|
apply_verdicts.add_argument("--data", type=Path, required=True)
|
|
93
93
|
apply_verdicts.add_argument("--verdicts", type=Path, required=True)
|
|
94
|
+
apply_verdicts.add_argument(
|
|
95
|
+
"--round", type=int, required=True, dest="round_number",
|
|
96
|
+
help="the verification round these verdicts were cast in; stamped on "
|
|
97
|
+
"every row so a later self-fix can be told from a current judgement",
|
|
98
|
+
)
|
|
94
99
|
return parser
|
|
95
100
|
|
|
96
101
|
|
|
@@ -322,11 +327,22 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
322
327
|
f"gate is re-derived from that table, so a verdict with nowhere to "
|
|
323
328
|
f"land would be scored as if it were never cast"
|
|
324
329
|
)
|
|
330
|
+
if args.round_number < 1:
|
|
331
|
+
raise PlanItemContractError("--round must be 1 or greater")
|
|
325
332
|
for item in recorded:
|
|
326
333
|
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
327
334
|
# Overwrite, never merge: the contract records one round at a time,
|
|
328
335
|
# and a merged table lets a previous round's votes keep voting.
|
|
329
|
-
|
|
336
|
+
#
|
|
337
|
+
# Each row carries the round it was cast in. A self-fix round
|
|
338
|
+
# rewrites the plan *after* a verification round, so an item left out
|
|
339
|
+
# of a later round keeps a verdict on text that has since changed —
|
|
340
|
+
# invisibly, because the gate reads the table without knowing any
|
|
341
|
+
# row's vintage. Stamping it here is what lets the validator tell a
|
|
342
|
+
# current judgement from one two rewrites old.
|
|
343
|
+
item["verdicts"] = [
|
|
344
|
+
{**row, "round": args.round_number} for row in rows[item["id"]]
|
|
345
|
+
]
|
|
330
346
|
write_json_atomic(args.data, data)
|
|
331
347
|
return {"ok": True, "operation": "apply-verdicts", "path": str(args.data)}
|
|
332
348
|
|
|
@@ -7746,6 +7746,11 @@
|
|
|
7746
7746
|
},
|
|
7747
7747
|
"note": {
|
|
7748
7748
|
"type": "string"
|
|
7749
|
+
},
|
|
7750
|
+
"round": {
|
|
7751
|
+
"description": "The plan-body verification round this verdict was cast in. A self-fix round rewrites the plan after a verification round, so a verdict whose round is at or before `selfFixRoundsApplied` judged text that has since changed. Without it there is no way to tell how many rewrites a surviving verdict predates, and a gate can pass on judgements two generations stale.",
|
|
7752
|
+
"type": "integer",
|
|
7753
|
+
"minimum": 1
|
|
7749
7754
|
}
|
|
7750
7755
|
}
|
|
7751
7756
|
}
|
|
@@ -5351,6 +5351,76 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
5351
5351
|
)
|
|
5352
5352
|
|
|
5353
5353
|
|
|
5354
|
+
def _validate_verdict_rounds_outlive_self_fix(
|
|
5355
|
+
data: dict,
|
|
5356
|
+
failures: list[str],
|
|
5357
|
+
) -> None:
|
|
5358
|
+
"""A verdict must judge the plan the gate is about to pass.
|
|
5359
|
+
|
|
5360
|
+
Rounds interleave with rewrites: round 1, self-fix 1, round 2, self-fix 2 …
|
|
5361
|
+
so a verdict cast in round R judged the text as it stood after self-fix
|
|
5362
|
+
R-1. If any self-fix ran afterwards — `selfFixRoundsApplied >= R` — that
|
|
5363
|
+
text has changed and the verdict is stale by construction. No semantic
|
|
5364
|
+
analysis is needed to know that; the arithmetic settles it.
|
|
5365
|
+
|
|
5366
|
+
The sibling `_validate_verdicts_match_current_subjects` cannot see this. It
|
|
5367
|
+
compares each row's own recorded `subject`, which catches a positional shift
|
|
5368
|
+
but not the case that matters here: an item whose own wording never changed
|
|
5369
|
+
while the stage it points at was rewritten under it. Observed on a real run
|
|
5370
|
+
— the gate read `passed-with-dissent` with zero blockers, and re-running one
|
|
5371
|
+
round flipped 3 of 27 items to `majority-disagree`, all correctness-critical,
|
|
5372
|
+
because their surviving verdicts predated two self-fix rounds.
|
|
5373
|
+
|
|
5374
|
+
Scoped to items this run verified: a `carriedForwardFromSeq` row belongs to
|
|
5375
|
+
the prior run's record and is judged by that run's seq, not this one's
|
|
5376
|
+
rounds.
|
|
5377
|
+
"""
|
|
5378
|
+
ip = data.get("implementationPlanning")
|
|
5379
|
+
if not isinstance(ip, dict):
|
|
5380
|
+
return
|
|
5381
|
+
pbv = ip.get("planBodyVerification")
|
|
5382
|
+
if not isinstance(pbv, dict):
|
|
5383
|
+
return
|
|
5384
|
+
applied = pbv.get("selfFixRoundsApplied")
|
|
5385
|
+
if not isinstance(applied, int) or applied < 1:
|
|
5386
|
+
# With no rewrite after any round there is nothing a verdict can be
|
|
5387
|
+
# stale against, and an unstamped row is then simply unremarkable.
|
|
5388
|
+
return
|
|
5389
|
+
|
|
5390
|
+
stale: list[str] = []
|
|
5391
|
+
unstamped: list[str] = []
|
|
5392
|
+
for item in pbv.get("planItems") or []:
|
|
5393
|
+
if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
|
|
5394
|
+
continue
|
|
5395
|
+
item_id = str(item.get("id") or "").strip()
|
|
5396
|
+
for verdict in item.get("verdicts") or []:
|
|
5397
|
+
if not isinstance(verdict, dict):
|
|
5398
|
+
continue
|
|
5399
|
+
round_number = verdict.get("round")
|
|
5400
|
+
if not isinstance(round_number, int) or isinstance(round_number, bool):
|
|
5401
|
+
unstamped.append(item_id)
|
|
5402
|
+
elif round_number <= applied:
|
|
5403
|
+
stale.append(item_id)
|
|
5404
|
+
if unstamped:
|
|
5405
|
+
failures.append(
|
|
5406
|
+
f"final-report data.json: plan item(s) {sorted(set(unstamped))} carry "
|
|
5407
|
+
f"a verdict with no `round`, and {applied} self-fix round(s) rewrote "
|
|
5408
|
+
"the plan. Without the round there is no way to tell whether the "
|
|
5409
|
+
"verdict judged the current text or a version two rewrites old. "
|
|
5410
|
+
"Re-record the round's votes with `okstra plan-items apply-verdicts "
|
|
5411
|
+
"--round <N>`."
|
|
5412
|
+
)
|
|
5413
|
+
if stale:
|
|
5414
|
+
failures.append(
|
|
5415
|
+
f"final-report data.json: plan item(s) {sorted(set(stale))} carry a "
|
|
5416
|
+
f"verdict from a round at or before self-fix round {applied}, so the "
|
|
5417
|
+
"text they judged has since been rewritten. The gate is computed "
|
|
5418
|
+
"from these votes, so passing on them declares a plan verified that "
|
|
5419
|
+
"nobody verified. Re-verify those items in a round after the last "
|
|
5420
|
+
'self-fix (plan-body-verification.md §"Round protocol" step 7).'
|
|
5421
|
+
)
|
|
5422
|
+
|
|
5423
|
+
|
|
5354
5424
|
def _validate_verdicts_match_current_subjects(
|
|
5355
5425
|
data: dict,
|
|
5356
5426
|
failures: list[str],
|
|
@@ -6367,6 +6437,7 @@ def validate_plan_body_section(
|
|
|
6367
6437
|
_validate_aborted_gate_has_clarification(data, failures)
|
|
6368
6438
|
_validate_round_recorded_verdicts(data, failures)
|
|
6369
6439
|
_validate_verdicts_match_current_subjects(data, failures)
|
|
6440
|
+
_validate_verdict_rounds_outlive_self_fix(data, failures)
|
|
6370
6441
|
_validate_plan_item_extraction_completeness(data, failures)
|
|
6371
6442
|
_validate_plan_item_subject_substance(data, failures)
|
|
6372
6443
|
_validate_plan_body_clarification_matching(data, failures)
|