okstra 0.169.0 → 0.170.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/docs/architecture.md +17 -1
- package/docs/cli.md +11 -1
- package/docs/for-ai/skills/okstra-setup.md +8 -0
- package/docs/project-structure-overview.md +3 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-error-log.py +38 -282
- package/runtime/prompts/duties/acceptance-critic.md +25 -5
- package/runtime/prompts/duties/acceptance-verifier.md +25 -5
- package/runtime/prompts/duties/analysis-worker.md +25 -5
- package/runtime/prompts/duties/code-reviewer.md +25 -5
- package/runtime/prompts/duties/common.md +15 -11
- package/runtime/prompts/duties/diagnosis-worker.md +44 -0
- package/runtime/prompts/duties/discovery-worker.md +44 -0
- package/runtime/prompts/duties/implementation-executor.md +25 -5
- package/runtime/prompts/duties/implementation-verifier.md +25 -5
- package/runtime/prompts/duties/lead.md +25 -5
- package/runtime/prompts/duties/planning-worker.md +44 -0
- package/runtime/prompts/duties/report-writer.md +25 -5
- package/runtime/prompts/duties/reverification-worker.md +25 -5
- package/runtime/prompts/duties/schedule-verifier.md +25 -5
- package/runtime/prompts/duties/scope-critic.md +25 -5
- package/runtime/prompts/duties/translator.md +25 -5
- package/runtime/prompts/lead/convergence.md +53 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +5 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -2
- package/runtime/python/okstra_ctl/agent_invocation.py +146 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +38 -0
- package/runtime/python/okstra_ctl/cmux.py +36 -19
- package/runtime/python/okstra_ctl/dispatch_core.py +317 -27
- package/runtime/python/okstra_ctl/dispatch_state.py +143 -9
- package/runtime/python/okstra_ctl/doctor.py +31 -0
- package/runtime/python/okstra_ctl/error_log_write.py +308 -0
- package/runtime/python/okstra_ctl/plan_derivations.py +94 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +114 -3
- package/runtime/python/okstra_ctl/run.py +7 -1
- package/runtime/python/okstra_ctl/schema_excerpt.py +34 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +17 -0
- package/runtime/python/okstra_ctl/worker_audit_check.py +26 -4
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +59 -9
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +24 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +12 -1
- package/runtime/python/okstra_project/resolver.py +34 -0
- package/runtime/skills/okstra-setup/references/project-config.md +38 -0
- package/runtime/validators/lib/fixtures.sh +9 -1
- package/runtime/validators/validate-run.py +37 -2
|
@@ -235,6 +235,15 @@ prompt/result/audit paths. The returned `promptPath` is the only body that may
|
|
|
235
235
|
be dispatched; do not append role prose or reconstruct model headers after
|
|
236
236
|
materialization.
|
|
237
237
|
|
|
238
|
+
If the dispatch gate then rejects that prompt, fix the task-instructions file
|
|
239
|
+
and re-run the same `materialize` call with `--replace-undispatched` — keep the
|
|
240
|
+
`--invocation-id`. A published prompt is otherwise immutable, so without that
|
|
241
|
+
flag the retry fails as `existing_invocation_conflict`; do NOT delete the
|
|
242
|
+
reservation under `prompts/.agent-invocations` and do NOT mint a second
|
|
243
|
+
invocation id to get around it, because both detach the audit chain from the
|
|
244
|
+
call it describes. The flag is checked: once any dispatch row names this
|
|
245
|
+
invocation, the prompt is history and the replacement is refused.
|
|
246
|
+
|
|
238
247
|
Run `okstra agent-prompt verify --run-manifest <path> --metadata
|
|
239
248
|
<metadataPath> --json` immediately before dispatch. A failed verification is a
|
|
240
249
|
pre-dispatch contract failure. For `runner=native-session`, pass only the
|
|
@@ -261,7 +270,7 @@ Call `await_workers(handles)` through the same adapter and apply the shared term
|
|
|
261
270
|
|
|
262
271
|
### Required reverify-prompt anchor headers (BLOCKING)
|
|
263
272
|
|
|
264
|
-
Every reverify prompt MUST start with these
|
|
273
|
+
Every reverify prompt MUST start with these 8 anchor headers — in this exact order, before any other content:
|
|
265
274
|
|
|
266
275
|
```
|
|
267
276
|
**Project Root:** <absolute-path>
|
|
@@ -269,7 +278,6 @@ Every reverify prompt MUST start with these 9 anchor headers — in this exact o
|
|
|
269
278
|
**Result Path:** runs/<task-type>/worker-results/<role-slug>-reverify-r<N>-<task-type>-<seq>.md
|
|
270
279
|
**Audit sidecar path:** <absolute-path>
|
|
271
280
|
Assigned worker prompt history path: <Project Root>/<Prompt History Path>
|
|
272
|
-
**Model:** <role>, <modelExecutionValue>
|
|
273
281
|
**Errors log path:** <absolute-path>
|
|
274
282
|
**Errors sidecar path:** <absolute-path>
|
|
275
283
|
**Read scope:** Read only the paths this prompt enumerates (`[Required reading]`, `## Inputs`, verification-target paths) plus source/evidence paths a finding must cite. Host session instructions (SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, `SKILL.md`, or other artifacts outside `<PROJECT_ROOT>/.okstra/`. If an un-enumerated file seems essential, record it under *Missing Information or Assumptions* instead of reading it.
|
|
@@ -281,7 +289,9 @@ Before dispatch, materialize `**Audit sidecar path:**` by passing the exact reve
|
|
|
281
289
|
|
|
282
290
|
The two errors paths carry the same absolute values the lead forwarded in the initial Phase 4 dispatch for that role (source: the launch prompt's `## Run Logs (error-log wiring)` section). Omitting either one makes `worker-dispatch` reject the CLI invocation before it starts the provider process — the path-delivery contract in [team-contract](./team-contract.md) "Error reporting" is not relaxed for reverify.
|
|
283
291
|
|
|
284
|
-
Relative to the Phase 4 anchor set rendered by `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`, a reverify prompt
|
|
292
|
+
Relative to the Phase 4 anchor set rendered by `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`, a reverify prompt drops two anchors whose targets lightweight mode never reads: `**Worker Preamble Path:**` and `**Coding preflight pack:**`.
|
|
293
|
+
|
|
294
|
+
**Where the composer's sections go.** `okstra agent-prompt materialize` (§"Invocation materialization gate") writes the dispatched body itself, as: these anchors, then the model-assignment block it appends (`**Provider:**`, `**Model:**`, `**Model execution value:**`, `**Runner:**`, `**Host runtime:**`, and `**Host model value:**` for a native host), then `## Duty Contract`, then `## Task Instructions` followed verbatim by the task-instructions file the lead wrote. So the lead authors only the last part, and every rule below about ordering — the phase boundary before the instruction headings, the `**Model:** <role>, <modelExecutionValue>` line — is about the lead's own file, not about the composed document. The composer's `**Model:** <modelExecutionValue>` anchor is a different line with a different shape; do not try to reshape it, and do not count it among the 8.
|
|
285
295
|
|
|
286
296
|
For an `antigravity` assignment, append the exact `PLAIN_FILE_WRITE_HEADER`
|
|
287
297
|
value from `okstra_ctl.worker_prompt_headers` immediately after
|
|
@@ -290,20 +300,28 @@ persisted initial Phase 4 prompt; do not paraphrase or reconstruct it. If the
|
|
|
290
300
|
persisted initial prompt does not contain that generated header, abort the
|
|
291
301
|
reverify dispatch and record a `contract-violation` event instead of
|
|
292
302
|
dispatching without the plain-file safeguard. This provider-specific header is
|
|
293
|
-
outside the common
|
|
303
|
+
outside the common 8-header count above. Other providers do not receive it.
|
|
294
304
|
|
|
295
305
|
The rationale for both drops is §"Reverify prompt: required-reading suppression" below.
|
|
296
306
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
instructions:
|
|
307
|
+
The task-instructions file the lead writes MUST open with this block, before any
|
|
308
|
+
`##` heading of its own:
|
|
300
309
|
|
|
301
310
|
```markdown
|
|
311
|
+
**Model:** <role>, <modelExecutionValue>
|
|
302
312
|
**Task Type:** <task-manifest taskType>
|
|
303
313
|
**Forbidden actions:**
|
|
304
314
|
<active-run-context workflow.forbiddenActions, verbatim>
|
|
305
315
|
```
|
|
306
316
|
|
|
317
|
+
This is the same placement `okstra_ctl.worker_prompt_body` uses for an initial
|
|
318
|
+
Phase 4 prompt, and it is where the checks look: `validate_reverify_prompt()`
|
|
319
|
+
reads the region after `## Task Instructions`, so a `**Model:**` line left in
|
|
320
|
+
the anchors is invisible to it, and a phase boundary written below the file's
|
|
321
|
+
first heading fails. Both rules are about this file — the composer's own
|
|
322
|
+
`## Duty Contract` heading sits above everything here and is not what they
|
|
323
|
+
measure against.
|
|
324
|
+
|
|
307
325
|
Do not summarize, shorten, or reconstruct the forbidden-actions text. The
|
|
308
326
|
selected adapter validates the task type and exact block through
|
|
309
327
|
`okstra_ctl.worker_prompt_contract.validate_reverify_prompt()` before starting
|
|
@@ -587,6 +605,34 @@ persisted assignment or either model value required by its runner is absent,
|
|
|
587
605
|
record `critic-skipped: model-unresolved`; never resolve a replacement model.
|
|
588
606
|
Result path: `runs/<task-type>/worker-results/<provider>-worker-critic-<task-type>-<seq>.md`.
|
|
589
607
|
|
|
608
|
+
**What the critic task-instructions file MUST contain (BLOCKING).** A critic
|
|
609
|
+
dispatch is not a reverify dispatch: `dispatchKind = "critic"` keeps
|
|
610
|
+
`audience = "analysis"`, so `worker_prompt_contract.validate_initial_prompts`
|
|
611
|
+
judges it by the full initial-analysis contract. Two of those requirements are
|
|
612
|
+
satisfied by the generated body for a Phase 4 worker
|
|
613
|
+
(`okstra_ctl.worker_prompt_body`) and by nothing at all for a critic, whose
|
|
614
|
+
instructions the lead writes — the materializer's anchor block supplies neither.
|
|
615
|
+
Put both in the instructions file:
|
|
616
|
+
|
|
617
|
+
```markdown
|
|
618
|
+
**Prompt Delivery Mode:** eager-include
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
and, under the file's `## Inputs`, exactly one line in this shape — the literal
|
|
622
|
+
label and the backticks are what the check matches, so a bare path or a
|
|
623
|
+
differently-worded label counts as zero:
|
|
624
|
+
|
|
625
|
+
```markdown
|
|
626
|
+
- Primary analysis packet: `<path ending in analysis-packet.md>`
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Omitting either one fails `okstra team dispatch --dispatch-kind critic` before
|
|
630
|
+
any process starts, reported as `<task-type> prompt contract: <worker>: exactly
|
|
631
|
+
one Primary analysis packet path is required (found 0)` and `exactly one
|
|
632
|
+
non-empty **Prompt Delivery Mode:** header is required`. Fix the instructions
|
|
633
|
+
file and re-materialize with `--replace-undispatched` (§"Invocation
|
|
634
|
+
materialization gate") rather than editing the published prompt.
|
|
635
|
+
|
|
590
636
|
The `-worker-` token is load-bearing, not decoration: the critic prompt carries the same generated anchor headers as every other worker ([team-contract](./team-contract.md) §"Worker prompts"), and its `**Audit sidecar path:**` comes from passing that result path through `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()`, which inserts `-audit-` after the token and raises without it. A `<provider>-critic-...` name leaves the lead choosing between breaking the contract and hand-inventing the sidecar name. Note that `originWorker` stays `"<provider>-critic"` — that is a worker id in the convergence state, not a filename, and the two do not have to match.
|
|
591
637
|
|
|
592
638
|
The critic prompt carries the full analysis contract those anchor headers belong to — worker preamble, error-contract path, audit sidecar, packet boundary — but it is **exempt from the initial-analysis equality group**. Initial analysis workers must receive byte-identical normalized bodies (`worker_prompt_contract.validate_analysis_prompt_set`), and a critic body is deliberately unlike them, so `okstra_ctl.worker_prompt_policy.resolve_prompt_plan` resolves `dispatchKind = "critic"` to `equality_group = None` while keeping `audience = "analysis"`. Never reshape a critic prompt to match the initial one to satisfy that check — matching it would delete the pass. **Enforced:** `tests/contract/test_critic_prompt_equality_exemption.py` pins both directions — the critic is exempt, and two mismatched *initial* prompts still fail.
|
|
@@ -270,7 +270,7 @@ okstra error-log append-from-worker \
|
|
|
270
270
|
|
|
271
271
|
`--agent`, `--agent-role`, and `--error-type` are **closed enums**, not free-form labels — the role names used elsewhere in these contracts (`Codex worker`, `Claude worker`) are rejected. Use exactly:
|
|
272
272
|
|
|
273
|
-
- `--agent` — `claude-worker` | `codex-worker` | `antigravity-worker` | `report-writer`
|
|
273
|
+
- `--agent` — `claude-worker` | `codex-worker` | `antigravity-worker` | `grok-worker` | `kimi-worker` | `report-writer`
|
|
274
274
|
- `--agent-role` — `lead` | `worker` | `report-writer`
|
|
275
275
|
- `--error-type` — `cli-failure` | `contract-violation` | `tool-failure`
|
|
276
276
|
|
|
@@ -197,8 +197,10 @@ CLI-wrapper calls go through `okstra worker-dispatch` and consume only
|
|
|
197
197
|
round before any host or provider process starts.
|
|
198
198
|
|
|
199
199
|
1. Lead runs `okstra plan-items extract --data <data.json> --output <state>/plan-items-....json`, places the persisted `items[]` verbatim in every verifier prompt with the compact `subject` and lossless `payload`, then runs `okstra plan-items validate --data <data.json> --items <state>/plan-items-....json`. Dispatch only after that exact-match validation succeeds.
|
|
200
|
+
|
|
201
|
+
**Then seed the landing table (BLOCKING):** `okstra plan-items seed --data <data.json>`. `apply-verdicts` in step 8 refuses a verdict whose item has no `planBodyVerification.planItems[]` row, and the report writer leaves that array empty — §5.5.9 is a lead substep that runs after Phase 6 authoring, so nothing before this step has filled it. The seed is idempotent by id and never touches an existing row, so it is safe to re-run between rounds and after a self-fix re-extraction. Skipping it makes step 8 fail with `the report's planBodyVerification has no row for [...]`, which reads as a transcription bug rather than a missing step.
|
|
200
202
|
2. For each analyser worker in the roster (`claude`, `codex`, and `antigravity` if opted in), lead constructs a reverify prompt using the template in §"Plan-body reverify prompt" below.
|
|
201
|
-
3. Dispatch uses the same wrapper infrastructure as finding convergence, so the `--role-slug` is the same canonical `<role>-worker` that convergence uses — not a round-specific slug. Result file path: `runs/<task-type>/worker-results/<role>-worker-plan-verify-r<N>-implementation-planning-<seq>.md` (e.g. `codex-worker-plan-verify-r1-implementation-planning-003.md`). The `-worker-` token is load-bearing twice over: §"Plan-body reverify prompt" requires the same anchor headers as convergence, whose `**Audit sidecar path:**` is derived by `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()` inserting `-audit-` after that token — a slug without it makes the header underivable and the helper raises. Record each `planItems[].verdicts[].worker` as the same `<role>-worker` string, because provenance compares it to this filename's prefix. **Enforced:** `tests/contract/test_reverify_dispatch_anchors.py` derives the sidecar from the documented name and re-extracts the prefix the provenance resolver uses.
|
|
203
|
+
3. Dispatch uses the same wrapper infrastructure as finding convergence, so the `--role-slug` is the same canonical `<role>-worker` that convergence uses — not a round-specific slug. Result file path: `runs/<task-type>/worker-results/<role>-worker-plan-verify-r<N>-implementation-planning-<seq>.md` (e.g. `codex-worker-plan-verify-r1-implementation-planning-003.md`). **`<seq>` is the report's sequence** — the one in this run's `final-report-<task-type>-<seq>` filename, NOT the `workerResults` sequence the initial analysis results carry. The two are equal in most runs and diverge in some (`reports: 004` alongside `workerResults: 005` is a real case), and provenance globs on the report's. Picking the other one makes `_validate_plan_body_verdict_provenance` report that no result file exists while the file is sitting in the directory. The `-worker-` token is load-bearing twice over: §"Plan-body reverify prompt" requires the same anchor headers as convergence, whose `**Audit sidecar path:**` is derived by `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()` inserting `-audit-` after that token — a slug without it makes the header underivable and the helper raises. Record each `planItems[].verdicts[].worker` as the same `<role>-worker` string, because provenance compares it to this filename's prefix. **Enforced:** `tests/contract/test_reverify_dispatch_anchors.py` derives the sidecar from the documented name and re-extracts the prefix the provenance resolver uses.
|
|
202
204
|
**Verdict provenance (BLOCKING).** Every verdict recorded in `planItems[].verdicts[]` MUST trace back to a dispatch that actually returned a result file at the path above. The whole gate — classification, self-fix eligibility, promotion, `gateBlockedBy` — is computed from these votes, so an unbacked vote lets the round be skipped while the gate still reads `passed`. **Enforced:** `validators/validate-run.py` `_validate_plan_body_verdict_provenance` fails any `verdicts[].worker` with no matching `<worker>-plan-verify-r<N>-<task-type>-<seq>.md` result file. Recording a `verification-error` for a dispatch that produced no result is the correct way to represent a failed worker — inventing an `AGREE` is a contract violation.
|
|
203
205
|
|
|
204
206
|
4. After all dispatches return, lead aggregates verdicts per `P-*` item across workers and classifies each:
|
|
@@ -233,6 +235,8 @@ round before any host or provider process starts.
|
|
|
233
235
|
|
|
234
236
|
When either fires, re-dispatch that verifier with a correction paragraph stating the exact nature of the violation and what IS checkable in this worktree. A byte-identical re-dispatch reproduces the same failure; a corrected one recovered 37 substantive verdicts from a worker whose first attempt answered `UNVERIFIABLE` to all 80 items. The environment exception in §"Planning-time environment gap" covers **running build and test commands only** — whether a referenced path exists, whether a command is declared in `package.json`, and whether the plan is internally consistent are all checkable without it, and a blanket "capability constraints prevent workspace resolution" is not a valid answer to any of them.
|
|
235
237
|
|
|
238
|
+
**How the corrective round is recorded.** The first prompt was dispatched, so it is immutable — `--replace-undispatched` refuses it, correctly. Materialize the correction under a NEW `--invocation-id` and a new prompt path. Before linking its result, retire the first attempt's link: `okstra agent-prompt reject-result --run-manifest <path> --dispatch-id <first dispatch id> --superseded-by <corrective dispatch id> --reason "<what was wrong with the returned result>"`. Without that step the corrective `link-result` fails with `agent result is already linked to another dispatch`, which is how a worker that ran for twenty minutes and wrote a good result ends up unrecordable. Nothing is deleted: the rejected link stays in `agentResultLinks` carrying `supersededBy` and `rejectionReason`, so the ledger shows both attempts and why the second exists.
|
|
239
|
+
|
|
236
240
|
Then lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below), **appending this round** — one new `roundHistory[]` entry plus this round's votes on each verified item's `planItems[].rounds[]`. The file accumulates across rounds; it is never truncated to the latest one. Lead then populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
|
|
237
241
|
7. **Self-fix loop (up to `selfFixMaxRounds`, targeting planner-fixable defects).** After aggregation, while at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`, lead runs self-fix rounds **before** promoting anything to the user:
|
|
238
242
|
- **Group the targets by cause before instructing (BLOCKING).** Blocked items are usually several derivatives of one defect — one constant declared twice, one responsibility given two owners — and the coverage rows that cite them fail as a consequence, not independently. Lead MUST partition this round's targets into cause groups and instruct each group as **"remove this cause"**, naming the derivatives it accounts for. **Handing report-writer a bare item list is forbidden**: patched one at a time, each correction leaves the sibling sections still asserting the old value, so the next round re-finds the same family and the budget drains without converging. Record the partition in `planBodyVerification.selfFixGroups[]` (`round`, `causeSummary`, `itemIds`). One group per item is a legitimate outcome only when the items genuinely share no cause — recorded that way, it is a visible diagnosis rather than a skipped one. **Enforced:** `validators/validate-run.py` `_validate_self_fix_grouping` requires the partition, ties `selfFixRoundsApplied` to the highest recorded round, and fails any corrected item that belongs to no group.
|
|
@@ -276,7 +276,7 @@ Lead instructs a self-fix round as **cause groups**, not a flat `P-*` list (`pla
|
|
|
276
276
|
|
|
277
277
|
**Carry the correction to its contradictions (BLOCKING).** "Only the section the item points to" bounds *which defect you fix*, not *how far the fix reaches*. When a correction changes a constant, an owner, a path, or a disposition, every other statement in the plan asserting the old value is now false — find and rewrite those too, in whatever section they sit.
|
|
278
278
|
|
|
279
|
-
**Enumerate before you edit.** Patching at the positions the lead named is what makes a round trade one defect for another: the correction lands, its siblings keep asserting the old value, and the next round finds a *new* contradiction the fix itself created. So for each cause group, first list every place the plan mentions that decision — grep the constant, the symbol, the path, the requirement ID across the whole plan body including rejected options, per-stage `Test case (…)` lines, `Acceptance`, `exitContract`, `stageValidation`, and the Requirement Coverage row — then reconcile each hit against the new decision and only then write. Record the enumeration in the group's supersession entry so the next round can see what was considered in scope. A patch that leaves its own contradictions standing produces the same defect class in the next round, so the loop spends its budget re-finding what the previous round created. Record each retirement in `implementationPlanning.supersessionLedger[]` exactly as the answer-carry-in rule requires (`_common-contract.md` §"Supersession").
|
|
279
|
+
**Enumerate before you edit.** Patching at the positions the lead named is what makes a round trade one defect for another: the correction lands, its siblings keep asserting the old value, and the next round finds a *new* contradiction the fix itself created. So for each cause group, first list every place the plan mentions that decision — grep the constant, the symbol, the path, the requirement ID across the whole plan body including rejected options, per-stage `Test case (…)` lines, `Acceptance`, `exitContract`, `stageValidation`, and the Requirement Coverage row — then reconcile each hit against the new decision and only then write. `okstra plan-items derivations --data <data.json> --response <user-response sidecar>` does that grep mechanically: it extracts the symbols, paths, and ids the answer names and returns every plan string that mentions one, as a pointer plus excerpt. Its output is candidates, not verdicts — which hits are now false is yours to decide — but starting from it is what stops the enumeration from being skipped, which is the observed failure (17 of 23 blocked items in one run were a recorded decision whose derivations were never swept). Record the enumeration in the group's supersession entry so the next round can see what was considered in scope. A patch that leaves its own contradictions standing produces the same defect class in the next round, so the loop spends its budget re-finding what the previous round created. Record each retirement in `implementationPlanning.supersessionLedger[]` exactly as the answer-carry-in rule requires (`_common-contract.md` §"Supersession").
|
|
280
280
|
|
|
281
281
|
**Record the reach.** For each cause group you rewrite, list the data.json paths you actually changed in that group's `rewrittenPaths`, and the subset of those lying outside the sections its `itemIds` point at in `outsideScopePaths` (e.g. `stages[0].stepwiseExecution`, `validationChecklist[3]`). Carrying a correction to its contradictions legitimately reaches past the flagged item, so the second list is a measurement and not a violation — no threshold is applied to either. It exists because "this round was a targeted correction, not a full regeneration" is currently a claim with nothing behind it, and a round that quietly rewrites the whole draft costs the same tokens every time it repeats. **Enforced:** `validators/validate-run.py` `_validate_self_fix_rewrite_scope` requires every `outsideScopePaths` entry to appear in `rewrittenPaths`.
|
|
282
282
|
|
|
@@ -17,6 +17,6 @@ Load the applicable coding conventions for every language the diff will touch, t
|
|
|
17
17
|
|
|
18
18
|
- **Resource selection — read the routed pack, never inline it here.** Use this worker prompt's `**Coding preflight pack:**` anchor header as the absolute path to the installed routed pack. Detect each touched file's language and framework from its extension or project manifest (`package.json`, `Cargo.toml`, `pyproject.toml`, `pom.xml`, `build.gradle*`, `prisma/schema.prisma`), then read that pack's resources via the Read tool by absolute path. Always read `overview.md` (the router) + `clean-code.md`, then select per the router's three ordered stages — Stage 1 language → `languages/<lang>.md`, Stage 2 framework → `frameworks/<fw>.md` (e.g. `frameworks/node-server.md` for server-side Node), Stage 3 architecture → `architectures/<arch>.md` (e.g. `architectures/hexagonal.md` for ports-and-adapters / NestJS-hex). Each stage is a list of rules; include EVERY matching resource (a change set can touch multiple languages/frameworks/architectures) — do not stop at the first match. These files are runtime resources, not Skill-tool skills, so always read them by path.
|
|
19
19
|
- **Declared architecture style — an authoritative Stage 3 input, and it binds.** Before selecting resources, read `<PROJECT_ROOT>/.okstra/project.json` and take `architecture.style`. A declared `hexagonal` selects `architectures/hexagonal.md` even when none of Stage 3's layout signals matched, so the declaration — not the directory shape — decides. A declared `layered` has no pack resource; its invariant applies from this line: dependencies run one direction only — an upper layer may import a lower one, never the reverse — and a variation point is extracted onto a layer boundary. A declared style makes this overlay binding rather than advisory, and which rule binds follows the style: under `hexagonal` the overlay's otherwise-advisory concrete-adapter item is blocking, so a service dependency you add or modify goes through a port instead of a concrete implementation and that placement violation is fixed before the write rather than recorded as a note; under `layered` what binds is the direction invariant just stated — your own judgement over the import list of every file the diff touches, plus extracting a variation point onto a layer boundary — while the concrete-adapter item stays advisory, since `layered` has no ports to route it through. An absent field, a `none` style, or an unreadable `project.json` changes nothing — Stage 3 stays detection-driven and its overlay stays advisory, leaving the language-agnostic principles below as the only always-binding layer. The verifier re-grades the same diff under the same declaration (`_implementation-verifier.md` → Static design & test-quality review), so a placement violation missed here returns as a verdict `FAIL`.
|
|
20
|
-
- **Project review rule packs:**
|
|
20
|
+
- **Project review rule packs:** a pack applies when either source names it — the task brief's `Source Material` / `Reporter Confirmations` cites its exact `SKILL.md` path, or `<PROJECT_ROOT>/.okstra/project.json` lists it under `reviewRulePacks` (absolute paths; a project's standing standard, so it applies to every run whether or not the brief mentions it). The two sources are a union. Read only those files and the `references/*.md` files they directly name; a declared path that will not open is recorded as `project-review-rules: declared <path> unreadable`, never silently dropped. Do not search parent directories or host skill catalogs. Apply those rules during implementation as a prevention pass, not a PR-comment generation workflow: do not dispatch reviewer subagents from the executor. For Fonts Ninja-style PR review packs, the executor must avoid newly introduced duplicate helper stacks, tautological tests that merely re-call the delegated helper, self-mocking, domain rules in adapters/ports, domain objects outside `domain/`, dead APIs, weak public names, and functions that fail the plain-English read.
|
|
21
21
|
- **Language-agnostic principles that ALWAYS bind (the TDD loop MUST satisfy them):** (1) no self-mocking of the SUT — stub/spy only injected collaborators, never the subject's own methods; (2) behavioral assertions on outcomes (return value, state, persisted rows, events, boundary calls) — never `toHaveBeenCalled*` on an internal helper as the only/primary assertion; (3) truthful names — a `get*` / `find*` that writes/inserts, or a name encoding the caller's use-case (`*ForInit`) or hiding a domain rule (`findValid*`), is a defect; (4) single-purpose functions ≤50 effective lines, plain-English readability. Self-mocking (1) — Enforced by `validators/detect_self_mock.py` (static); absent `qa/self-mock-*.json` sidecar BLOCKS at `validate-run.py`.
|
|
22
22
|
- **Graceful degradation (codex / antigravity executor runtimes, or any runtime where the resolved coding-preflight pack files are absent or unreadable):** do NOT skip the gate — apply the agnostic principles above plus the project's own `CLAUDE.md` / `CONTRIBUTING` / formatter+lint config, and record `coding-conventions: resource-unavailable → applied <project rules + agnostic principles>` in the final report. Never claim a resource read that did not happen.
|
|
@@ -156,7 +156,7 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
|
|
|
156
156
|
|
|
157
157
|
- **Scope (no silent sampling).** Enumerate every changed source/test file via `git diff --name-only <base>...HEAD` and review each one. Skipping a changed file silently is a `contract-violated` outcome. If a file's language has no reference and is not covered by the agnostic checks below, record `design-review skipped: <file> (language=<x> no reference)` — never pass it silently.
|
|
158
158
|
- **Load the same conventions the executor used via the routed pack.** Use this worker prompt's `**Coding preflight pack:**` anchor header as the absolute path to the installed routed pack. Read `overview.md` first, then `clean-code.md`, then apply the router's three ordered stages: language, framework, architecture. In each stage, iterate every rule, treat a rule as matched when any listed condition is true, and accumulate every matching resource — including `frameworks/node-server.md` for server-side Node work and `architectures/hexagonal.md` for ports-and-adapters / NestJS-hex layouts. Degrade to the agnostic checks below when the resolved pack is unreadable, and record either `coding-conventions: resources=<...>` or `coding-conventions: resource-unavailable → applied <project rules + agnostic principles>`. The verifier does NOT inline language rules — it loads the same situation-specific resources as the executor preflight.
|
|
159
|
-
- **Load
|
|
159
|
+
- **Load the project's review rule packs.** A pack applies when either source names it — the task brief's `Source Material` / `Reporter Confirmations` cites its exact `SKILL.md` path, or `<PROJECT_ROOT>/.okstra/project.json` (the same file Tier 2's `qaCommands` comes from) lists it under `reviewRulePacks`. The two sources are a union, and a `reviewRulePacks` entry is the project's standing standard: it applies to this run whether or not the brief mentions it. Read only those files and the `references/*.md` files they directly name. Do not search parent directories or host skill catalogs. Apply the rules as an overlay on this static review, but do NOT dispatch extra reviewer agents unless the task explicitly configured them. Record `project-review-rules: <paths read>`, `project-review-rules: declared <path> unreadable`, or `project-review-rules: none declared or cited` in the worker result — an unreadable declared pack is a recorded gap, not a skip.
|
|
160
160
|
- **Declared architecture style promotes the placement overlay from advisory to binding.** Read `<PROJECT_ROOT>/.okstra/project.json` — the same file Tier 2's `qaCommands` comes from — take `architecture.style`, and record `architecture-style: <hexagonal|layered|none>` in the worker result next to the `coding-conventions:` line. A declared `hexagonal` counts the overlay as loaded even when none of the router's Stage 3 layout signals matched, so the **Hexagonal** blocking check below applies in full, and the concrete-adapter injection listed under Advisory findings is promoted to a blocking finding → verdict `FAIL`, not a `should-fix`. A declared `layered` has no pack resource; its binding invariant is direction — an upper layer may import a lower one, never the reverse — so a changed file whose import list reaches back up a layer, or around a layer boundary, is a blocking placement violation cited `path:line` from that import list. The `layered` half is worker judgement: no machine check reads layer names, so a missed reverse dependency is a missed finding, not a validator failure. A `none` style, an absent field, or an unreadable `project.json` leaves this section exactly as it is today — Stage 3 stays detection-driven and the placement items stay advisory. **Enforced:** `scripts/okstra_project/resolver.py` `resolve_architecture` reads this same field for the planning-side rule in `validators/validate-run.py` `_validate_variation_point_analysis`, and `_validate_verifier_fail_blocks_verdict` (cited under the DB gate below) keeps the resulting `FAIL` from being dropped during synthesis.
|
|
161
161
|
- **Blocking checks (any hit → verdict `FAIL`, cited `path:line` + rule name, recommended fix recorded — the verifier does NOT apply it):**
|
|
162
162
|
- **New duplication / DRY:** two or more newly added or meaningfully modified blocks implement the same helper stack, transform, or domain rule. Literal copy-paste is always blocking; semantically equivalent transforms across services are blocking unless the approved plan explicitly justified keeping them separate. Recommend the shared module location.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
- **External Tier 3 de-duplication exception.** A DB/IO/SQL surface covered by an in-scope Tier 3 entry whose `requires` include `db`, `http`, or `external` is governed by the External QA outcome policy. Its non-PASS or unavailable result MUST NOT generate a second legacy db-test-not-configured or mock-only blocker solely for that same Tier 3 non-PASS or unavailable result. Tier 1 or Tier 2 failures remain blocking, and DB surfaces without declared external Tier 3 coverage remain blocking.
|
|
21
21
|
- no new defects introduced — the diff does not break previously-working behaviour and adds no new bug (logic/off-by-one, null/empty handling, resource leaks, broken error paths)
|
|
22
22
|
- scope conformance — the delivered diff stays within the approved plan's scope; flag out-of-scope edits, unrelated file changes, leftover debug/commented-out code, and unintended deletions
|
|
23
|
-
- project review-rule packs
|
|
23
|
+
- project review-rule packs — a pack applies when either source names it: the task brief's `Source Material` / `Reporter Confirmations` cites its exact `SKILL.md` path, or `<PROJECT_ROOT>/.okstra/project.json` lists it under `reviewRulePacks` (the project's standing standard, applying whether or not the brief mentions it). The two sources are a union. Read only those files and the `references/*.md` files they directly name. Do not search parent directories or host skill catalogs. Apply the rules as an acceptance overlay (record `project-review-rules: <paths read>`, `project-review-rules: declared <path> unreadable`, or `project-review-rules: none declared or cited`). This is a static review pass, not a PR-comment workflow — do NOT dispatch reviewer subagents. Because this phase verifies the **whole-task merged diff**, it is the gate that catches **cross-stage findings a per-stage `implementation` verifier structurally cannot see** (each implementation run reviews only its own stage diff): most importantly two cross-stage conditions: (a) the same helper stack / transform / domain rule duplicated across stages or services — byte-identical duplication is always an Acceptance Blocker, and semantically-equivalent transforms across services are blockers unless the approved plan explicitly justified keeping them separate; (b) an API newly orphaned because its only caller was removed in a different stage. A confirmed cross-stage duplication of this kind is an Acceptance Blocker (`major`+) that cites every `path:line` location and names the shared-module location to converge on. (Single-stage scope sees only one stage, so it cannot raise cross-stage findings — note that limitation rather than implying coverage.)
|
|
24
24
|
- Residual-tracked — note as Residual Risk unless severe enough to block:
|
|
25
25
|
- unresolved edge cases
|
|
26
26
|
- regression risk in adjacent code paths not directly changed
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
- **Follow established patterns**: in existing codebases, conform to current conventions. Targeted cleanup of a file you are already modifying is acceptable; unrelated refactors are not.
|
|
44
44
|
- **Variation-point extraction (OCP)**: when the same behavior is served by two or more resources / implementations — stated in the brief, or foreseeable from a sibling task or the code you inspected — the plan MUST record it in `variationPointAnalysis` and include an option that extracts the variation point behind an interface (a port, or a strategy the next implementation plugs into), scored against the non-extracted option in the trade-off matrix. Penalize an option that branches on resource identity inside a service (one `if` / `switch` arm per implementation): adding the next implementation then means editing that same call site again, which is the closed-for-extension shape this principle exists to catch. This does not contradict YAGNI below: YAGNI drops *speculative* variation (a second implementation nobody named), while a behavior with two implementations already on the table is a present fact, not a forecast. **Enforced:** the `variationPointAnalysis` bullet under `Required deliverable shape` names the schema / validator / `P-Var-*` enforcement points.
|
|
45
45
|
- **YAGNI ruthlessly**: drop features, abstractions, and configuration knobs that do not serve the stated requirement. The test is a *present* caller, not a plausible one — an abstraction whose only justification is a requirement nobody has stated is this rule's target, while a behavior with two implementations already on the table belongs to `Variation-point extraction` above. **Enforced:** the §5.5.9 plan-body verification round raises it as a `P-Opt-*` `DISAGREE(e)`, majority-gated (`prompts/lead/plan-body-verification.md` "`P-Opt-<N>` carries the **YAGNI judgement**"), and one phase later the `implementation` verifier's Static design gate fails the stage on a caller-less identifier (`prompts/profiles/_implementation-verifier.md` "Caller-less identifier (YAGNI / orphan)"), which the executor's `Pre-commit diff review sweep` is expected to have already removed. Note what is NOT enforced: no validator reads plan prose for a speculative abstraction, so passing `Scope provenance` below is not evidence this rule was applied — the verdict is a worker judgement or it is nothing.
|
|
46
|
-
- **Project review-rule preflight**:
|
|
46
|
+
- **Project review-rule preflight**: a pack applies when either source names it — the task brief's `Source Material` / `Reporter Confirmations` cites its exact `SKILL.md` path, or `<PROJECT_ROOT>/.okstra/project.json` lists it under `reviewRulePacks` (the project's standing standard, applying whether or not the brief mentions it). The two sources are a union. Read only those files and the `references/*.md` files they directly name. Do not search parent directories or host skill catalogs. Do not run the PR-review workflow here; extract only the rules. For Fonts Ninja-style TS/NestJS review packs, this means planning away known review findings before code exists: shared transforms instead of duplicate helper stacks, behavioral tests instead of collaborator-tautology assertions, domain rules in domain modules rather than repositories/adapters, domain objects under `domain/`, plain-English functions, truthful/specific names, and no dead APIs introduced by the plan.
|
|
47
47
|
- Expected output emphasis:
|
|
48
48
|
- feasible plan options
|
|
49
49
|
- dependency and risk visibility
|
|
@@ -196,7 +196,7 @@
|
|
|
196
196
|
3. **Internal consistency** — option file lists, trade-off matrix, and recommended step list must agree on file paths, names, and signatures. A symbol called `clearLayers()` in the matrix and `clearFullLayers()` in the steps is a bug.
|
|
197
197
|
4. **Ambiguity check** — any requirement that could be read two ways must be made explicit or moved to the `## 1. Clarification Items` table as a `Blocks=approval` row.
|
|
198
198
|
5. **Scope check** — if the recommended plan now spans multiple independent subsystems, recommend splitting into separate planning runs rather than shipping an oversized plan. Then walk the plan in the expansion direction: for every stage, name the Requirement Coverage row that demanded it, and for every requirement row, read its `Source` cell as a skeptic — does the cited brief heading actually exist, and does a `derived:` rationale state a real technical consequence rather than a preference? Move anything that fails to a `Blocks=approval` clarification row.
|
|
199
|
-
6. **Review-rule preflight check** —
|
|
199
|
+
6. **Review-rule preflight check** — when a project review rule pack applies (cited by the brief, or declared in `project.json` `reviewRulePacks` — see the preflight rule above), map each relevant rule to the recommended option. Reject the draft if it knowingly creates a violation that the later PR reviewer would flag, unless the plan records a specific rationale and follow-up. In particular, scan for repeated helper stacks across planned files, tests that assert delegation to the same calculator/helper they exercise, public names that hide side effects, domain rules placed in repositories/adapters, and APIs made dead by this change.
|
|
200
200
|
7. **Plan-body verification reconciliation (BLOCKING for implementation-planning).** For every §5.5.9 `planItems[]` entry whose verdicts make it `majority-disagree`, set that item's `clarificationId` to a `C-<N>` row that MUST exist in `## 1. Clarification Items` with `Kind` chosen per the standard policy and `Blocks=approval`. **Enforced:** `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item has no `clarificationId`, or its `clarificationId` is dangling / points at a non-`approval` row. For `partial-consensus` and `dissent-isolated` plan-items, the dissenting opinion lives in §5.5.9 `Dissent log` and is NOT promoted to §5.
|
|
201
201
|
8. **Stage Map self-check** — for every stage, count the effective rows of its `Stepwise Execution Order` table by hand; reject the draft if any stage exceeds 8. Confirm each stage declares a non-empty `Slice value:` and `Acceptance:` line, the three `Test case (success|boundary|failure):` lines (or carries a `TDD exemption:` line), and that its first step `action` starts with `RED:` with a later `GREEN:` — this is what validator S10 enforces, including S10d on the test-case lines. Read each stage's three test-case lines as a reviewer: reject any that restates the happy path in all three slots, leaves `boundary` blank, or writes `N/A` where a real edge input exists. Walk the `depends-on` graph and confirm it is a DAG (no cycle, no self-reference). For each `depends-on` link, confirm it encodes a real data/contract dependency — do NOT add links to serialise unrelated work, and do NOT split a stage merely to create more parallel stages. **Parallel-safety:** for every pair of `depends-on (none)` stages, confirm their `Stage Exit Contract` predicted file sets are disjoint; if they share a file, merge them or add a `depends-on` link (validator S9 rejects overlap). **Project-boundary:** confirm no stage mixes edits from two projects (different repo/`PROJECT_ROOT` or different top-level deployable module); if any stage does, split it per project. For multi-project plans, confirm each stage's `title` carries its `[<project>]` tag and the `Cross-project parallelism:` line under the table records the parallel-vs-sequenced determination (with the forcing dependency) for every project pair; for cross-repo work, confirm it is split into separate per-repo runs (required — one run structurally cannot touch another repo) rather than crammed into one task's stages.
|
|
202
202
|
9. **Cross-project dependency check** — confirm you have not missed a dependency on another repo / another top-level deployable module / a published package. If `dependencyMigrationRisk` has a `kind: cross-project` row, confirm a matching `direction: upstream-precondition` `XP-NNN` row exists in `crossProjectDependencies`, and re-read as a reviewer whether its `requiredWork` is the concrete work the other side must actually build rather than an abstract phrase ("other side's work done") — validator S only checks existence, so concreteness is the self-review's responsibility. Confirm cross-repo work is split into a separate run + XP row instead of being crammed into one task's stages, and that the cross-project substance is not duplicated in `§3 Recommended Next Steps` but lives only in `§5.4 Cross-Project Dependencies`.
|
|
@@ -18,6 +18,9 @@ from typing import Iterator, Literal, Mapping, get_args
|
|
|
18
18
|
AgentAudience = Literal[
|
|
19
19
|
"lead",
|
|
20
20
|
"analysis-worker",
|
|
21
|
+
"discovery-worker",
|
|
22
|
+
"diagnosis-worker",
|
|
23
|
+
"planning-worker",
|
|
21
24
|
"implementation-executor",
|
|
22
25
|
"implementation-verifier",
|
|
23
26
|
"acceptance-verifier",
|
|
@@ -31,7 +34,36 @@ AgentAudience = Literal[
|
|
|
31
34
|
]
|
|
32
35
|
|
|
33
36
|
_SUPPORTED_AUDIENCES = frozenset(get_args(AgentAudience))
|
|
37
|
+
# The section set IS the duty contract's shape: a duty author adding a role file
|
|
38
|
+
# reads these names, and `_validate_duty_sections` refuses a file that misses one.
|
|
39
|
+
# The check is structural — it proves every required section exists and carries
|
|
40
|
+
# text, NOT that the text is a real contract. A one-line placeholder passes it;
|
|
41
|
+
# what keeps a section substantive is review, and the rule that earns a section a
|
|
42
|
+
# place here at all: a sentence that reads the same in another duty file belongs
|
|
43
|
+
# in `common.md` or nowhere.
|
|
44
|
+
COMMON_DUTY_SECTIONS = (
|
|
45
|
+
"Assignment fidelity",
|
|
46
|
+
"Required inputs",
|
|
47
|
+
"Evidence first",
|
|
48
|
+
"Authority and scope",
|
|
49
|
+
"Collaboration and independence",
|
|
50
|
+
"Instruction precedence",
|
|
51
|
+
"Conflict handling",
|
|
52
|
+
"Completion honesty",
|
|
53
|
+
)
|
|
54
|
+
ROLE_DUTY_SECTIONS = (
|
|
55
|
+
"Responsibility",
|
|
56
|
+
"Required conduct",
|
|
57
|
+
"Decision principles",
|
|
58
|
+
"Authority and boundaries",
|
|
59
|
+
"Evidence standard",
|
|
60
|
+
"Collaboration contract",
|
|
61
|
+
"Completion criteria",
|
|
62
|
+
"Forbidden conduct",
|
|
63
|
+
"Blocked-state reporting",
|
|
64
|
+
)
|
|
34
65
|
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
66
|
+
_DUTY_SECTION_RE = re.compile(r"(?m)^## ([^\n]+)\s*$")
|
|
35
67
|
_TOP_LEVEL_KEYS = {
|
|
36
68
|
"schemaVersion",
|
|
37
69
|
"invocationId",
|
|
@@ -125,6 +157,9 @@ class AgentInvocationRequest:
|
|
|
125
157
|
prompt_path: Path
|
|
126
158
|
metadata_path: Path
|
|
127
159
|
dispatch_kind: str
|
|
160
|
+
# Republish a prompt this invocation id already wrote, but only while no
|
|
161
|
+
# dispatch has used it. See `_publish_or_reuse`.
|
|
162
|
+
replace_undispatched: bool = False
|
|
128
163
|
|
|
129
164
|
|
|
130
165
|
@dataclass(frozen=True)
|
|
@@ -210,6 +245,7 @@ def load_common_duty_contract(duty_root: Path) -> DutyContract:
|
|
|
210
245
|
raise AgentInvocationError(f"invalid common duty frontmatter: {path}")
|
|
211
246
|
if fields["id"] != "common" or fields["kind"] != "common":
|
|
212
247
|
raise AgentInvocationError(f"invalid common duty frontmatter: {path}")
|
|
248
|
+
_validate_duty_sections(body, COMMON_DUTY_SECTIONS, "common", path)
|
|
213
249
|
return DutyContract(
|
|
214
250
|
id="common",
|
|
215
251
|
version=_parse_version(fields["version"], path),
|
|
@@ -254,13 +290,13 @@ def prepare_agent_invocation(
|
|
|
254
290
|
materialized = _materialize(request)
|
|
255
291
|
if materialized.reservation_root is None:
|
|
256
292
|
with _exclusive_lock(_prompt_lock_path(request.prompt_path)):
|
|
257
|
-
_publish_or_reuse(materialized)
|
|
293
|
+
_publish_or_reuse(request, materialized)
|
|
258
294
|
else:
|
|
259
295
|
reservation_lock = materialized.reservation_root / "publish.lock"
|
|
260
296
|
with _exclusive_lock(reservation_lock):
|
|
261
297
|
_publish_or_reuse_reservation(materialized)
|
|
262
298
|
with _exclusive_lock(_prompt_lock_path(request.prompt_path)):
|
|
263
|
-
_publish_or_reuse(materialized)
|
|
299
|
+
_publish_or_reuse(request, materialized)
|
|
264
300
|
errors = verify_agent_invocation(
|
|
265
301
|
request.metadata_path,
|
|
266
302
|
project_root=request.project_root,
|
|
@@ -889,7 +925,9 @@ def _publish_or_reuse_reservation(materialized: _MaterializedInvocation) -> None
|
|
|
889
925
|
_publish_exclusive(path, _pretty_json(reservation))
|
|
890
926
|
|
|
891
927
|
|
|
892
|
-
def _publish_or_reuse(
|
|
928
|
+
def _publish_or_reuse(
|
|
929
|
+
request: AgentInvocationRequest, materialized: _MaterializedInvocation
|
|
930
|
+
) -> None:
|
|
893
931
|
prompt = materialized.prepared.prompt_path
|
|
894
932
|
metadata = materialized.prepared.metadata_path
|
|
895
933
|
prompt_exists = prompt.exists()
|
|
@@ -897,7 +935,8 @@ def _publish_or_reuse(materialized: _MaterializedInvocation) -> None:
|
|
|
897
935
|
if metadata_exists and not prompt_exists:
|
|
898
936
|
raise _existing_conflict("metadata exists without prompt")
|
|
899
937
|
if prompt_exists and prompt.read_bytes() != materialized.prompt_bytes:
|
|
900
|
-
|
|
938
|
+
_replace_undispatched(request, materialized)
|
|
939
|
+
return
|
|
901
940
|
if prompt_exists and metadata_exists:
|
|
902
941
|
if metadata.read_bytes() != materialized.metadata_bytes:
|
|
903
942
|
raise _existing_conflict("existing metadata differs")
|
|
@@ -907,6 +946,73 @@ def _publish_or_reuse(materialized: _MaterializedInvocation) -> None:
|
|
|
907
946
|
_publish_exclusive(metadata, materialized.metadata_bytes)
|
|
908
947
|
|
|
909
948
|
|
|
949
|
+
def _replace_undispatched(
|
|
950
|
+
request: AgentInvocationRequest, materialized: _MaterializedInvocation
|
|
951
|
+
) -> None:
|
|
952
|
+
"""Rewrite a prompt this invocation id wrote but no dispatch ever used.
|
|
953
|
+
|
|
954
|
+
Immutability protects the audit chain: a prompt that a worker ran must keep
|
|
955
|
+
reading the way it ran. A prompt that failed a pre-dispatch gate ran
|
|
956
|
+
nowhere, and there the same rule cost a lead its only clean way forward —
|
|
957
|
+
fix the instruction file and the republish is refused, so the remaining
|
|
958
|
+
moves were to delete the reservation by hand or to burn a second invocation
|
|
959
|
+
id and blur which call is which. Both damage the chain this protects.
|
|
960
|
+
|
|
961
|
+
The exit is explicit (`--replace-undispatched`) and it is checked, not
|
|
962
|
+
trusted: a single recorded dispatch against this invocation id — worker or
|
|
963
|
+
agent — puts the prompt back under the original rule.
|
|
964
|
+
"""
|
|
965
|
+
if not request.replace_undispatched:
|
|
966
|
+
raise _existing_conflict("existing prompt differs")
|
|
967
|
+
dispatched = _recorded_dispatch_ids(request)
|
|
968
|
+
if dispatched:
|
|
969
|
+
raise _existing_conflict(
|
|
970
|
+
"existing prompt differs and this invocation was already "
|
|
971
|
+
f"dispatched as {', '.join(sorted(dispatched))}"
|
|
972
|
+
)
|
|
973
|
+
_publish_exclusive(
|
|
974
|
+
materialized.prepared.prompt_path, materialized.prompt_bytes, replace=True
|
|
975
|
+
)
|
|
976
|
+
_publish_exclusive(
|
|
977
|
+
materialized.prepared.metadata_path,
|
|
978
|
+
materialized.metadata_bytes,
|
|
979
|
+
replace=True,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _recorded_dispatch_ids(request: AgentInvocationRequest) -> set[str]:
|
|
984
|
+
"""Dispatch rows in team-state that already reference this invocation id.
|
|
985
|
+
|
|
986
|
+
Both ledgers are read: `agentDispatches` is written by the host-native
|
|
987
|
+
spec-link gate, `workerDispatches` by the deterministic worker dispatch, and
|
|
988
|
+
a prompt used by either one is history.
|
|
989
|
+
"""
|
|
990
|
+
if request.run_manifest_path is None:
|
|
991
|
+
return set()
|
|
992
|
+
manifest = _load_json_object(request.run_manifest_path, "run manifest")
|
|
993
|
+
team_state_value = manifest.get("teamStatePath")
|
|
994
|
+
if not isinstance(team_state_value, str) or not team_state_value.strip():
|
|
995
|
+
return set()
|
|
996
|
+
team_state_path = _project_path(
|
|
997
|
+
request.project_root, team_state_value, must_exist=False
|
|
998
|
+
)
|
|
999
|
+
team_state = _read_json_if_object(team_state_path)
|
|
1000
|
+
if team_state is None:
|
|
1001
|
+
return set()
|
|
1002
|
+
found: set[str] = set()
|
|
1003
|
+
for key in ("agentDispatches", "workerDispatches"):
|
|
1004
|
+
rows = team_state.get(key)
|
|
1005
|
+
if not isinstance(rows, list):
|
|
1006
|
+
continue
|
|
1007
|
+
for row in rows:
|
|
1008
|
+
if not isinstance(row, Mapping):
|
|
1009
|
+
continue
|
|
1010
|
+
if row.get("invocationId") != request.invocation_id:
|
|
1011
|
+
continue
|
|
1012
|
+
found.add(str(row.get("dispatchId") or request.invocation_id))
|
|
1013
|
+
return found
|
|
1014
|
+
|
|
1015
|
+
|
|
910
1016
|
def _existing_conflict(detail: str) -> AgentInvocationError:
|
|
911
1017
|
return AgentInvocationError(
|
|
912
1018
|
f"existing_invocation_conflict: {detail}",
|
|
@@ -930,7 +1036,7 @@ def _prompt_lock_path(prompt_path: Path) -> Path:
|
|
|
930
1036
|
return prompt_path.with_name(prompt_path.name + ".publish.lock")
|
|
931
1037
|
|
|
932
1038
|
|
|
933
|
-
def _publish_exclusive(path: Path, body: bytes) -> None:
|
|
1039
|
+
def _publish_exclusive(path: Path, body: bytes, *, replace: bool = False) -> None:
|
|
934
1040
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
935
1041
|
temp_path: Path | None = None
|
|
936
1042
|
try:
|
|
@@ -944,7 +1050,14 @@ def _publish_exclusive(path: Path, body: bytes) -> None:
|
|
|
944
1050
|
handle.write(body)
|
|
945
1051
|
handle.flush()
|
|
946
1052
|
os.fsync(handle.fileno())
|
|
947
|
-
|
|
1053
|
+
# `link` is what makes a first publish exclusive — it fails rather than
|
|
1054
|
+
# overwrite. A sanctioned replacement wants the opposite and still needs
|
|
1055
|
+
# to be atomic, so it swaps the same fully-written temp file into place.
|
|
1056
|
+
if replace:
|
|
1057
|
+
os.replace(temp_path, path)
|
|
1058
|
+
temp_path = None
|
|
1059
|
+
else:
|
|
1060
|
+
os.link(temp_path, path)
|
|
948
1061
|
except FileExistsError as exc:
|
|
949
1062
|
raise _existing_conflict(f"published path already exists: {path}") from exc
|
|
950
1063
|
finally:
|
|
@@ -1461,6 +1574,7 @@ def _load_role_duty(path: Path) -> DutyContract:
|
|
|
1461
1574
|
raise AgentInvocationError(f"unknown duty audience: {audience}")
|
|
1462
1575
|
if fields["kind"] != "role" or fields["id"] != audience:
|
|
1463
1576
|
raise AgentInvocationError(f"invalid role duty frontmatter: {path}")
|
|
1577
|
+
_validate_duty_sections(body, ROLE_DUTY_SECTIONS, "role", path)
|
|
1464
1578
|
return DutyContract(
|
|
1465
1579
|
id=fields["id"],
|
|
1466
1580
|
version=_parse_version(fields["version"], path),
|
|
@@ -1492,6 +1606,32 @@ def _parse_duty_file(path: Path) -> tuple[dict[str, str], str]:
|
|
|
1492
1606
|
return fields, "".join(lines[end + 1 :]).lstrip("\n")
|
|
1493
1607
|
|
|
1494
1608
|
|
|
1609
|
+
def _validate_duty_sections(
|
|
1610
|
+
body: str,
|
|
1611
|
+
required: tuple[str, ...],
|
|
1612
|
+
kind: str,
|
|
1613
|
+
path: Path,
|
|
1614
|
+
) -> None:
|
|
1615
|
+
matches = list(_DUTY_SECTION_RE.finditer(body))
|
|
1616
|
+
sections: dict[str, str] = {}
|
|
1617
|
+
for index, match in enumerate(matches):
|
|
1618
|
+
name = match.group(1).strip()
|
|
1619
|
+
if name in sections:
|
|
1620
|
+
raise AgentInvocationError(f"duplicate {kind} duty section {name}: {path}")
|
|
1621
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
|
|
1622
|
+
sections[name] = body[match.end() : end].strip()
|
|
1623
|
+
missing = [name for name in required if name not in sections]
|
|
1624
|
+
if missing:
|
|
1625
|
+
raise AgentInvocationError(
|
|
1626
|
+
f"missing {kind} duty sections: {', '.join(missing)}: {path}"
|
|
1627
|
+
)
|
|
1628
|
+
empty = [name for name in required if not sections[name]]
|
|
1629
|
+
if empty:
|
|
1630
|
+
raise AgentInvocationError(
|
|
1631
|
+
f"empty {kind} duty sections: {', '.join(empty)}: {path}"
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
|
|
1495
1635
|
def _parse_version(value: str, path: Path) -> int:
|
|
1496
1636
|
try:
|
|
1497
1637
|
version = int(value)
|
|
@@ -41,6 +41,7 @@ from .dispatch_state import (
|
|
|
41
41
|
DispatchError,
|
|
42
42
|
link_agent_dispatch_result,
|
|
43
43
|
record_verified_agent_dispatch,
|
|
44
|
+
reject_agent_dispatch_result,
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
|
|
@@ -71,6 +72,13 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
71
72
|
materialize.add_argument("--model-role")
|
|
72
73
|
materialize.add_argument("--model", default="")
|
|
73
74
|
materialize.add_argument("--purpose")
|
|
75
|
+
materialize.add_argument(
|
|
76
|
+
"--replace-undispatched",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="rewrite a prompt this invocation id already wrote, allowed only "
|
|
79
|
+
"while no dispatch has referenced it — the exit for a prompt that "
|
|
80
|
+
"failed a pre-dispatch gate and never ran",
|
|
81
|
+
)
|
|
74
82
|
materialize.add_argument("--json", action="store_true")
|
|
75
83
|
|
|
76
84
|
verify = commands.add_parser("verify")
|
|
@@ -103,6 +111,18 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
103
111
|
)
|
|
104
112
|
record_dispatch.add_argument("--json", action="store_true")
|
|
105
113
|
|
|
114
|
+
reject_result = commands.add_parser(
|
|
115
|
+
"reject-result",
|
|
116
|
+
help="mark a linked result rejected so a corrective re-dispatch can "
|
|
117
|
+
"claim its path",
|
|
118
|
+
)
|
|
119
|
+
_common_paths(reject_result)
|
|
120
|
+
reject_result.add_argument("--run-manifest", required=True)
|
|
121
|
+
reject_result.add_argument("--dispatch-id", required=True)
|
|
122
|
+
reject_result.add_argument("--superseded-by", required=True)
|
|
123
|
+
reject_result.add_argument("--reason", required=True)
|
|
124
|
+
reject_result.add_argument("--json", action="store_true")
|
|
125
|
+
|
|
106
126
|
link_result = commands.add_parser("link-result")
|
|
107
127
|
_common_paths(link_result)
|
|
108
128
|
link_result.add_argument("--run-manifest", required=True)
|
|
@@ -149,6 +169,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
149
169
|
if args.command == "record-dispatch":
|
|
150
170
|
_record_dispatch(args)
|
|
151
171
|
return 0
|
|
172
|
+
if args.command == "reject-result":
|
|
173
|
+
_reject_result(args)
|
|
174
|
+
return 0
|
|
152
175
|
_link_result(args)
|
|
153
176
|
return 0
|
|
154
177
|
except (
|
|
@@ -185,6 +208,20 @@ def _record_dispatch(args: argparse.Namespace) -> None:
|
|
|
185
208
|
_emit(record, args.json)
|
|
186
209
|
|
|
187
210
|
|
|
211
|
+
def _reject_result(args: argparse.Namespace) -> None:
|
|
212
|
+
project_root = _project_root(args.project_root)
|
|
213
|
+
row = reject_agent_dispatch_result(
|
|
214
|
+
project_root=project_root,
|
|
215
|
+
run_manifest_path=_project_input(
|
|
216
|
+
project_root, args.run_manifest, "run manifest"
|
|
217
|
+
),
|
|
218
|
+
dispatch_id=args.dispatch_id,
|
|
219
|
+
superseded_by=args.superseded_by,
|
|
220
|
+
reason=args.reason,
|
|
221
|
+
)
|
|
222
|
+
_emit(row, args.json)
|
|
223
|
+
|
|
224
|
+
|
|
188
225
|
def _link_result(args: argparse.Namespace) -> None:
|
|
189
226
|
project_root = _project_root(args.project_root)
|
|
190
227
|
link = link_agent_dispatch_result(
|
|
@@ -329,6 +366,7 @@ def _materialize_run(
|
|
|
329
366
|
prompt_path=prompt_path,
|
|
330
367
|
metadata_path=prompt_path.with_name(prompt_path.name + ".meta.json"),
|
|
331
368
|
dispatch_kind=args.dispatch_kind,
|
|
369
|
+
replace_undispatched=args.replace_undispatched,
|
|
332
370
|
))
|
|
333
371
|
|
|
334
372
|
|