okstra 0.198.2 → 0.199.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.
Files changed (35) hide show
  1. package/docs/architecture/storage-model.md +10 -0
  2. package/docs/cli.md +4 -3
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  7. package/runtime/prompts/lead/plan-body-verification.md +12 -5
  8. package/runtime/prompts/lead/report-writer.md +1 -1
  9. package/runtime/prompts/profiles/_common-contract.md +1 -1
  10. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  11. package/runtime/python/okstra_ctl/analysis_packet.py +46 -0
  12. package/runtime/python/okstra_ctl/direct_work.py +109 -0
  13. package/runtime/python/okstra_ctl/group_context.py +14 -1
  14. package/runtime/python/okstra_ctl/material.py +29 -0
  15. package/runtime/python/okstra_ctl/model_io/lines.py +1 -0
  16. package/runtime/python/okstra_ctl/model_io/renderers.py +6 -0
  17. package/runtime/python/okstra_ctl/plan_items.py +39 -5
  18. package/runtime/python/okstra_ctl/plan_items_cli.py +61 -6
  19. package/runtime/python/okstra_ctl/recap.py +6 -0
  20. package/runtime/python/okstra_ctl/render.py +8 -4
  21. package/runtime/python/okstra_ctl/report_narrative.py +4 -4
  22. package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
  23. package/runtime/python/okstra_ctl/run.py +5 -0
  24. package/runtime/python/okstra_ctl/set_work_status.py +90 -40
  25. package/runtime/python/okstra_ctl/task_list_cli.py +2 -0
  26. package/runtime/python/okstra_ctl/worker_prompt_headers.py +1 -0
  27. package/runtime/python/okstra_project/state.py +4 -0
  28. package/runtime/schemas/final-report-v3.0.schema.json +10 -0
  29. package/runtime/skills/okstra-inspect/SKILL.md +6 -1
  30. package/runtime/skills/okstra-inspect/facets/recap.md +6 -1
  31. package/runtime/skills/okstra-inspect/facets/status.md +12 -0
  32. package/runtime/templates/reports/html/assets/base.css +4 -0
  33. package/runtime/templates/reports/html/assets/base.js +30 -0
  34. package/runtime/validators/validate-run.py +47 -6
  35. package/runtime/validators/validate_session_conformance.py +5 -0
@@ -242,6 +242,16 @@ Path segments are normalized into slugs. If a slug would be empty, as can happen
242
242
 
243
243
  ## Task manifest contract
244
244
 
245
+ `okstra set-work-status` can register an existing brief without creating a run. Such a task
246
+ has `registrationSource: direct`, its identity and brief path, and user-managed `workStatus`.
247
+ No workflow phase or run status is synthesized. A direct completion stores its work and
248
+ verification summary in `<task-root>/work-records/<digest>.json`; `latestWorkRecordPath`
249
+ points to it and each record's `previousRecordPath` preserves earlier completions. Actual
250
+ run preparation preserves these fields. Group memory records the direct source and result
251
+ path; status, recap, and later run inputs expose that path without treating it as a final report.
252
+ The command checks required completion summaries and reports sharing failures separately
253
+ from saved state (`tests/inspect/test_okstra_set_work_status.py`).
254
+
245
255
  `task-manifest.json` is the canonical metadata file the active Okstra lead uses to understand task continuity.
246
256
  As a general rule, this manifest records the absolute `projectRoot` path only once and expresses the remaining generated paths as project-relative fields whenever possible.
247
257
 
package/docs/cli.md CHANGED
@@ -816,8 +816,9 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
816
816
  | `okstra plan-items <prepare\|prompt\|validate-prepared> --run-manifest <path> …` | Bind the implementation-planning verification queue to the run manifest. `prepare` extracts the exact queue from `--narrative` and, when `designPreparation.mode` is `no-design-inputs` and the Stage Map has one row, flips `convergence.planBodyVerification.gating` to `false` (stdout `Gating`). `prompt` emits its fixed lossless view, ending with the parser-facing `## Response format` block so the block reaches every verifier with the queue; `validate-prepared` proves the prepared queue still matches the narrative. With `--state <plan-body-verification.json>` the round is a re-verification: `prepare` also carries each queued item's recorded votes and `selfFixNote` into the envelope as `priorRounds`, `prompt` renders them as that item's `**Prior round dissent**` block behind a re-verification preamble, and `validate-prepared` re-derives the carry and rejects an envelope that dropped it. Python resolves the convergence-owned state path, so model callers never choose it. |
817
817
  | `okstra plan-items seed --narrative <report-narrative.md> --state <plan-body-verification.json> [--prior-state <previous plan-body-verification.json>]` | Create the convergence-owned `planBodyVerification.planItems[]` rows every verdict lands in, from the same deterministic extraction `extract` uses. The historical v2 form is `--data <data.json>`. Idempotent by id: an existing row keeps its verdicts and carried fields. Reports `seeded` / `existing` counts. `--prior-state` carries the previous **run**'s verdicts into this one: a newly seeded item whose `contentHash` equals that run's `verifiedContentHash` for the same id inherits its `verdicts[]` and is tagged `carriedForwardFromSeq` with the seq read off the prior filename, so round 1 does not re-judge text nobody changed. A matching id alone never carries — `P-*` ids are positional and shift. It requires `--state`, refuses a prior state whose task root differs from the one `--state` lives under (the state file carries no task identity, so its path is the only identity there is), and when it carries anything it rewrites the sibling `plan-items-*.json` `dispatchQueue` the way `incremental-carry` does. Adds `carried` / `carriedForwardFromSeq` to the reported counts. |
818
818
  | `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker-id>=<result.md>… --round <N>` | Read each worker's Markdown verdict directly, validate every current `P-*` id, reject duplicate worker submissions, and overwrite that round's stored verdicts. `--items <plan-items artifact>` narrows the assignment to what a partial round actually dispatched — pass the `--tie-vote` artifact on a critic tie round, or every item the critic was never given is refused as unanswered. The unanswered check still applies inside the narrowed set. The historical `--verdicts <file>` form remains automation compatibility only. Without `--append` every recorded verdict row of the queued items is replaced; when a row belongs to a round `complete-round` never closed, the command refuses before writing and names the `complete-round --round <M>` to run first. `--discard-open-rounds` replaces anyway — the recovery path when those rounds are being re-applied from their result files in order (the discarded rows are printed); it also restores `dispatchQueue` to the items those result files answer, since the persisted queue belongs to the latest round and an earlier round's verdicts would otherwise be refused as outside it. |
819
- | `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N> [--self-fix-note <item-id>=<markdown-file>]… [--self-fix-group <cause-file>=<item-id>[,<item-id>...]]… [--self-fix-stop-reason <all-resolved\|no-progress\|max-rounds-reached>]` | After `plan-verify` succeeds, atomically derive and record the round's per-item votes, gate result, participant counts from the actual assigned roster, immutable completion time, convergence history, and optional self-fix notes/groups read from Markdown files. `--self-fix-group` requires `--self-fix-stop-reason` — there is no default. `--self-fix-stop-reason` alone records a stop for a round that rewrote nothing and leaves `selfFixGroups` / `selfFixRoundsApplied` untouched. Models do not write the state JSON. Stdout also carries `nextDispatch`. |
820
- | `okstra plan-items next-dispatch --state <plan-body-verification.json> [--run-manifest <path>]` | After `apply-verdicts`, decide whether this round opens a worker batch. `kind: none` — missing-dependency `UNVERIFIABLE` only, no new batch. `kind: worker-correction` — re-prompt only those workers; peers stay idle. `kind: critic-tie` — unsettled analyser 1-1 on a run that rostered a critic, `critic-worker` on those item ids only. `kind: user-decision` — the same 1-1 on a run with no critic rostered: no in-band vote can break it, so open one `okstra approval-decision open` per item (classification `noncritical-dissent`) plus its `## 1. Clarification Items` row and dispatch no further verification for them. `--run-manifest` is what tells the two apart (`invocationAssignments` `critic/*`); without it the answer stays `critic-tie`. A missing path is never environment-unverifiable. |
819
+ | `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N> [--self-fix-note <item-id>=<markdown-file>]… [--self-fix-group <cause-file>=<item-id>[,<item-id>...]]… [--self-fix-stop-reason <all-resolved\|no-progress\|max-rounds-reached>]` | After `plan-verify` succeeds, atomically derive and record the round's per-item votes, gate result, participant counts from the actual assigned roster, immutable completion time, convergence history, and optional self-fix notes/groups read from Markdown files. `--self-fix-group` requires `--self-fix-stop-reason` — there is no default. `--self-fix-stop-reason` alone records a stop for a round that rewrote nothing and leaves `selfFixGroups` / `selfFixRoundsApplied` untouched. Models do not write the state JSON. Stdout also carries `nextDispatch`. A second automatic rewrite is rejected; the one rewrite may contain several cause groups. `selfFixRoundsApplied` remains its verification round number, not a rewrite count. |
820
+ | `okstra plan-items next-dispatch --state <plan-body-verification.json> [--run-manifest <path>]` | After `apply-verdicts`, decide whether this round opens a worker batch. `kind: none` — missing-dependency `UNVERIFIABLE` only, no new batch. `kind: worker-correction` — re-prompt only those workers; peers stay idle. `kind: critic-tie` — unsettled analyser 1-1 on a run that rostered a critic, `critic-worker` on those item ids only. `kind: user-decision` — the same 1-1 on a run with no critic rostered: no in-band vote can break it, so open one `okstra approval-decision open` per item (classification `noncritical-dissent`) plus its `## 1. Clarification Items` row and dispatch no further verification for them. `--run-manifest` is what tells the two apart (`invocationAssignments` `critic/*`); without it the answer stays `critic-tie`. A missing path is never environment-unverifiable. After the single automatic self-fix, remaining items route to `lead-decision` first, then `user-decision`, with no further automatic worker batch. |
821
+ | `okstra plan-items resolve-dissent --state <plan-body-verification.json> --item <P-id> --decision-file <markdown-file>` | Record the lead decision, its authority within agreed scope, and cited evidence after the one self-fix. Accepts only current, noncritical design judgements; facts, user-input requirements and non-results require user confirmation. Keeps original votes and appends the decision to the report dissent log. The decision expires when its content, scope or verdicts change. |
821
822
  | `okstra plan-items correction-prompt --state <plan-body-verification.json> --run-manifest <path> --worker <id>` | Emit the planning-time environment-gap paragraph, then the assigned queue. The environment exception is first. Exits 2 unless `next-dispatch` named that worker as a blanket-UNVERIFIABLE correction target. |
822
823
  | `okstra error-log append-observed --out <errors.jsonl> --task-key <key> --phase <task-type> --agent <assigned-worker-id> --agent-role worker --model <model> --error-type tool-failure --command-file <markdown-file> --command-kind <kind> --message-file <markdown-file> [--cause <cause> --evidence-file <kind=file>]…` | Worker-facing typed error recording surface. Python validates and serializes the JSONL record; a worker supplies scalar identity fields plus Markdown files for free-form command, message, and probe content, never a JSON sidecar or JSON argument. `sandbox-denied` requires both `targetProbe` and `controlProbe` evidence files. |
823
824
  | `okstra config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | Manage persistent settings such as `pr-template-path` with atomic JSON writes |
@@ -844,7 +845,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
844
845
  | `okstra incremental-scope <args…>` | Decide re-verify vs carry-forward scope for an `implementation-planning` clarification re-run. Thin shim into `scripts/okstra_ctl/incremental_scope.py` (deterministic): it reads the dependency graph from the prior run `data.json`'s `implementationPlanning.stageMap` and returns `mode:"incremental"` only when the base-ref SHA is unchanged and the affected stages' `downstream_stage_closure` covers at most half of all stages; `--full-reason` (selected option / Stage Map / approach) still forces `mode:"full"`. An answered `C-NNN` that traces to no stage returns `mode:"unresolved"` rather than full — pass `--impacted` with the stage numbers or `--full-reason`. `--run-manifest <path>` is required for a decision (not for `--preview`): the same decision is written to the record that manifest names in `incrementalDecisionPath`, so the report writer's authoring contract and `okstra incremental-carry` read it instead of CSVs the lead re-typed. `mode: "unresolved"` is a question back to the lead and is deliberately not recorded. `--preview --prev-data <path> --answered-clarifications <csv>` runs the link half alone — no base SHA, no side effects — and prints `{wouldForceFull, unlinkedIds, reason}`; unlinked ids set `wouldForceFull: false` and fill `unlinkedIds` |
845
846
  | `okstra incremental-carry <args…>` | Merge carried-forward plan-item verdicts into an incremental re-run. Contract v3 takes `--prev-data`, `--cur-narrative`, and the convergence-owned `--state`; it verifies carried stage rows and writes only `--out-state`, tagging copied verdicts with `carriedForwardFromSeq`. Pass `--decision <incrementalDecisionPath>` for the two stage sets — the same record the report writer's authoring contract was built from — instead of `--carry-stages` / `--reverify-stages`, which the lead re-typed off stdout; the two forms cannot be combined, and a record whose `mode` is not `incremental` is refused. Unchanged `P-Val-*` / `P-Req-*` / `P-Rb-*` rows whose extract hash still matches are carried the same way, and the sibling `plan-items-*.json` `dispatchQueue` is rewritten to match. The historical v2 `--cur-data --out` form remains readable. Ownership, scope, item, or schema drift raises `CarryError` and forces a full fallback. |
846
847
  | `okstra code-review target --task-key <k> --stage <N> [--project-root <dir>] [--cwd <dir>] [--json]` / `okstra code-review target --branch <name> [--base <ref>] [--date <YYYY-MM-DD>] [--project-root <dir>] [--cwd <dir>] [--json]` | Resolve what a code review reads and where its result file goes. Output is always JSON, so `--json` only makes that explicit. `--project-root` and `--cwd` are shared pre-dispatch arguments and apply to both modes; `--cwd` is only consulted when `--project-root` is absent. Both modes return `{ ok, projectRoot, mode, worktreePath, branch, baseCommit, headCommit, reviewPath, round }`; stage mode additionally returns `taskKey`, `taskRoot`, and `stage`. Stage mode takes the diff base from the `base_ref` recorded on that stage's worktree-registry row when it was provisioned — not from a rule re-applied at review time — and names the result `.okstra/tasks/<task-group>/<task-id>/code-reviews/stage-<NN>.md`, where a re-review of the same stage becomes `-r2`, `-r3`, … (the `round` field). Only a legacy row provisioned before `base_ref` was recorded falls back to re-deriving the base through `stage_targets`, and a failure there is reported as `stage_base_unresolved`. `worktreePath` comes back empty whenever the stage worktree is not usable as a live checkout — the registry row is no longer `active` (whole-task final-verification released it), the row never carried a path, or the recorded directory is gone — and the review then reads the `branch` ref instead. Branch mode uses `--base` when given, otherwise the merge-base with the default branch (`refs/remotes/origin/HEAD`, else `main`/`master`), and names the result `.project-docs/code-reviews/<branch>/<YYYY-MM-DD>-<NN>.md`, where `<NN>` (the `round` field) is the next sequence number for that date — the highest already on disk plus one. Read-only: it resolves paths and creates no directory and no file, so the review directory does not exist until the caller writes the report. Backend for the okstra-code-review skill |
847
- | `okstra set-work-status <token> <todo\|in-progress\|blocked\|done> [--note <text>] [--task-group <g>] [--project-root <dir>]` | Update user-managed `workStatus` in task-manifest.json, along with `workStatusUpdatedAt` and, when `--note` is supplied, `workStatusNote`. `<token>` is a full task key or bare task ID. It uses the manifest renderer's serialization rules and returns `stage:"ambiguous"` plus `matches[]` when ambiguous |
848
+ | `okstra set-work-status <token> <todo\|in-progress\|blocked\|done> [--note <text> \| --note-file <path>] [--task-group <g>] [--project-root <dir>]` | Update user-managed `workStatus`. Existing briefs can be registered without a run. Direct completion requires a work and verification summary; use a UTF-8 note file for longer results. Records are shared through group context, inspect, and subsequent runs without claiming cross-verification. Ambiguous IDs return `matches[]`. A `share` failure with `statusRecorded:true` can be retried with the same command. |
848
849
  | `okstra worktree-lookup <project-id> <task-group> <task-id>` | Return the `worktree_registry.lookup` result: reserved path, branch, base ref, and current status |
849
850
  | `okstra worktree-status [--path <dir>] [--check-clean]` | Answer "is this worktree clean?" over source paths only, excluding what okstra provisioned there — `.okstra`, the configured sync entries (`.project-docs`, `.claude`, …), and any nested stage worktree. A bare `git status --porcelain` in a task worktree is never empty for that reason, so a plan step asserting a clean tree with one fails on okstra's scaffolding instead of on the stage's own work; this is the same gate `handoff` and stage integration use. Output is JSON `{ ok, path, clean, entries, excluded }` where `entries` holds the `git status --short` rows that made it dirty. Exit code is 0 regardless unless `--check-clean` is given, which exits 1 on a dirty tree so it can stand as a shell assertion (`okstra worktree-status --check-clean`). okstra writes `stage-<N>-exit` itself when it settles the stage, so a plan step must not tag. A path outside a git work tree exits 2 rather than reporting a clean tree |
850
851
  | `okstra plan-validate <plan-path>` | Run `_validate_approved_plan` and report frontmatter `approved` recognition plus unresolved Blocks=approval rows |
@@ -178,7 +178,7 @@ The Module column below is where the command's behaviour lives — a `src/` modu
178
178
  | `integrate-stages` | `scripts/okstra_ctl/stage_integrate.py` | Merge verified stages into the task worktree and clean stage worktrees |
179
179
  | `task-list`, `task-show` | `scripts/okstra_ctl/task_list_cli.py`, `scripts/okstra_ctl/task_show_cli.py` | Task/run introspection for skills; `task-show` consumes the Python task read-side snapshot |
180
180
  | `resolve-task-key` | `scripts/okstra_ctl/resolve_task_key.py` | Resolve a bare task-id to candidate task-keys from the project catalog |
181
- | `set-work-status` | `scripts/okstra_ctl/set_work_status.py` | Set a task's user-managed `workStatus` in task-manifest.json (Python: `okstra_ctl.set_work_status`) |
181
+ | `set-work-status` | `scripts/okstra_ctl/set_work_status.py`, `scripts/okstra_ctl/direct_work.py` | Set user-managed `workStatus`, register an existing brief without a run, and share direct completion records through group memory and task queries |
182
182
  | `time-report`, `log-report`, `error-report`, `error-zip` | `scripts/okstra_ctl/time_report.py`, `scripts/okstra_ctl/log_report.py`, `scripts/okstra_ctl/error_report.py`, `scripts/okstra_ctl/error_zip.py` | Read-side task runtime, wrapper log, and error aggregation helpers |
183
183
  | `run-audit` | `scripts/okstra_ctl/run_audit.py` | Anomaly detection — checks run artifacts against progress invariants and reports invariant violations, read-only (Python: `okstra_ctl.run_audit`) |
184
184
  | `worker-liveness` | `scripts/okstra_ctl/worker_liveness.py` | Report whether pending workers are still alive, so the lead's poll ends a stalled wait early instead of paying the deadline (Python: `okstra_ctl.worker_liveness`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.198.2",
3
+ "version": "0.199.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.198.2",
3
- "builtAt": "2026-09-11T13:42:28.980Z",
2
+ "package": "0.199.0",
3
+ "builtAt": "2026-09-11T19:30:28.119Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -465,7 +465,7 @@ Distinct from Phase 5.5 finding convergence:
465
465
 
466
466
  Lead's responsibilities in this sub-step (in order):
467
467
 
468
- For a new `implementation-planning` run, the fixed order is initial verification → one planner self-fix → targeted re-verification → user gate. The initial verification is round 1 and the targeted re-verification is round 2. A second automatic self-fix is a contract violation. When `okstra plan-items prepare` reports `"gating": false` (one-stage `no-design-inputs` plan), skip the self-fix loop and the sweep batch: extraction and round 1 still run, then go to the user gate. Two-or-more stages, a PREP item, or non-empty `designPreparation.items` keep `gating: true` and the full order.
468
+ For a new `implementation-planning` run, the fixed order is initial verification → one planner self-fix → targeted re-verification → lead decision or immediate user confirmation. The initial verification is round 1 and the targeted re-verification is round 2. A second automatic self-fix is a contract violation. When `okstra plan-items prepare` reports `"gating": false` (one-stage `no-design-inputs` plan), skip the self-fix loop and the sweep batch: extraction and round 1 still run, then go to the user gate. Two-or-more stages, a PREP item, or non-empty `designPreparation.items` keep `gating: true` and the full order.
469
469
 
470
470
  1. Build the queue with `okstra plan-items prepare --narrative <report-writer-narrative.md> --run-manifest <run-manifest>`, place the output of `okstra plan-items prompt --run-manifest <run-manifest>` verbatim in every verifier prompt, then run `okstra plan-items validate-prepared --narrative <report-writer-narrative.md> --run-manifest <run-manifest>`. Python resolves the one convergence-owned state path from that run identity. The lead MUST NOT summarise, select, omit, reorder, or renumber the queue. Each prompt uses the compact subject plus the lossless payload, and asks every item:
471
471
 
@@ -48,7 +48,7 @@ Plan-body verification is configured under `convergence.planBodyVerification` in
48
48
  |---------|---------|-------------|
49
49
  | `enabled` | `true` | If `false`, the round is skipped and the approval gate is not blocked by this round (legacy behaviour). |
50
50
  | `maxRounds` | `1` | Upper bound. Plan-body verification is consistency / completeness checking, not fact checking — additional rounds rarely help. Range 1–3. |
51
- | `selfFixMaxRounds` | `1` | One report-writer rewrite at most. The initial verification is round 1; targeted re-verification is round 2 after that rewrite. |
51
+ | `selfFixMaxRounds` | `1` | Fixed limit, not configurable: one automatic report-writer rewrite at most. Skip when nothing is fixable. `plan_items_cli._record_self_fixes`, `validate-run._validate_self_fix_grouping`, and session activity validation enforce the limit. |
52
52
  | `gating` | `true` | If `true` (default), `majority-disagree` blocks approval. If `false`, the round is advisory-only and never blocks approval. Prepare emits `true` because the plan does not exist yet. After the report-writer draft, `okstra plan-items prepare` (and `seed`) flip it to `false` when `designPreparation.mode` is `no-design-inputs` and the Stage Map has exactly one row. That path keeps extraction and one verification round and does not run the self-fix loop or a sweep batch. Two-or-more stages, a PREP item, or non-empty `designPreparation.items` keep `gating=true`. `--no-plan-verification` is the separate manual opt-out (`enabled=false`). **Enforced:** `okstra_ctl.plan_items.advisory_plan_body_gating`, `validators/validate-run.py` `_validate_advisory_plan_body_gating`. "never blocks approval" is enforced by the `gating is False` early return in `_recompute_plan_body_gate`, `_gate_blocking_causes`, `_validate_plan_body_clarification_matching`, and `_validate_self_fix_before_clarification` — an advisory round neither demands a `blocks=approval` row nor an exhausted self-fix budget. |
53
53
 
54
54
  Default values are emitted into the manifest by `scripts/okstra_ctl/render.py` (`_build_convergence_block`). The ctx knob `OKSTRA_PLAN_VERIFICATION=false` flips `planBodyVerification.enabled` to false. `gating=false` is not that opt-out: extraction and one round still run.
@@ -423,7 +423,8 @@ round before any host or provider process starts.
423
423
  | `worker-correction` | Re-dispatch **only** those workers. Peers are not re-run. The queue does not become a new round. Place the output of `okstra plan-items correction-prompt --worker <id> --run-manifest … --state …` first in that worker's prompt — the environment-exception paragraph is first. 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. |
424
424
  | `queue-reverify` | An unsettled tie on a blocking kind. Legacy kind; current scoring emits `critic-tie` instead. |
425
425
  | `critic-tie` | An unsettled analyser tie on a run that rostered a critic. Dispatch `critic-worker` on those `itemIds` only. The critic's verdict settles the split. Do not re-run the original two. |
426
- | `user-decision` | The same unsettled tie on a run with **no** critic rostered. There is no in-band vote left, so open no worker batch: for each `itemId` run `okstra approval-decision open` with classification `noncritical-dissent` and write the matching `## 1. Clarification Items` row at `Blocks=approval`, exactly as step 8 does for a surviving `majority-disagree`. Do not dispatch further verification for those items and do not re-run the original two. |
426
+ | `lead-decision` | After the single self-fix: settle eligible, evidenced design judgements through `resolve-dissent` as described in step 8. No worker batch. |
427
+ | `user-decision` | A criticless tie before self-fix, or an unresolved item outside lead authority afterward. Ask immediately through the step 8 approval-blocker protocol. No worker batch. |
427
428
 
428
429
  A referenced **path** that does not exist is still `DISAGREE(b)` / a fact probe, never environment-unverifiable. **Enforced:** `okstra_ctl.plan_items.next_dispatch` / `correction_prompt_text`.
429
430
 
@@ -432,7 +433,7 @@ round before any host or provider process starts.
432
433
  **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.
433
434
 
434
435
  Then run `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N>`. Python appends one immutable round history entry, records each verified item's votes, derives the current projection from the actual assigned roster, and stamps `completedAt` after the preceding verification command succeeds. The file accumulates across rounds; it is never truncated to the latest one. Report assembly later projects the completed nested `planBodyVerification` into the final record.
435
- 7. **Self-fix loop (one rewrite, targeting planner-fixable defects).** After round 1, lead may run one report-writer rewrite when at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`. The targeted re-verification after that rewrite is round 2. After round 2, stop automatic self-fix regardless of outcome. Classify every remaining item as `user-decision`, `noncritical-dissent`, or `correctness-critical`. A second automatic self-fix is a contract violation. The fixed order is initial verification one planner self-fix targeted re-verification user gate.
436
+ 7. **Self-fix loop (one rewrite, targeting planner-fixable defects).** After the initial verification, lead may run one report-writer rewrite when a `majority-disagree` item has a majority of `DISAGREE` verdicts at `fixability == planner-fixable`. Re-verify changed items once, preserving verdicts on unchanged content. Then stop automatic self-fix regardless of outcome and follow step 8. The fixed order is initial verification → one planner self-fix → targeted re-verification → lead decision or immediate user confirmation. A second automatic self-fix is rejected by `plan_items_cli._record_self_fixes`; `_validate_self_fix_grouping` and session activity validation detect multiple recorded rewrites. No rewrite is needed when no item qualifies.
436
437
  - **Group the targets by cause before instructing (BLOCKING).** Blocked items are usually several derivatives of one defect. Lead partitions this round's targets into cause groups and instructs each group as **"remove this cause"**, naming the derivatives it accounts for. The convergence command owns the persisted group and correction fields; the lead does not edit JSON state.
437
438
  - lead instructs report-writer to rewrite the items in each cause group (NOT a full draft regeneration): one `rewrite` entry per item in a corrections ledger, whose `rule` states the cause group's required outcome; procedure in [report-writer](./report-writer.md) §"Corrective report-writer dispatch (ledger required)".
438
439
  - 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.
@@ -446,12 +447,18 @@ round before any host or provider process starts.
446
447
  - **Loop termination.** The convergence command owns the round count and stop reason; the lead never edits either field in the state file. Both are recorded by `okstra plan-items complete-round`, and its two self-fix flags are not interchangeable. `--self-fix-group` is a claim that this round rewrote something, so it sets `selfFixGroups` and `selfFixRoundsApplied` and **requires** `--self-fix-stop-reason` — there is no default, because the value that used to fill in (`all-resolved`) is the one value step 8 reads as "nothing was left unresolved", which then forbids promoting the items the round did leave unresolved. `--self-fix-stop-reason` on its own is how a round that rewrote nothing records that the loop stops there; it writes `selfFixStopReason` and touches neither `selfFixGroups` nor `selfFixRoundsApplied`, because `validators/validate-run.py` `_validate_self_fix_grouping` requires the highest `selfFixGroups[].round` to equal `selfFixRoundsApplied` — a round number raised without a group has no value that passes. A user-directed correction does not consume the automatic self-fix limit, and a verification failure after that correction does not restart the automatic loop:
447
448
  - `all-resolved` — no planner-fixable `majority-disagree` item remains. Exit.
448
449
  - `no-progress` — the round resolved **zero** planner-fixable items relative to the previous round. Exit even with budget left: the same rewrite would repeat. Newly *introduced* defects count against progress, so a rewrite that trades one defect for another stops the loop rather than churning. A round that re-targets only what the previous round left unresolved is this same conclusion reached one dispatch earlier — exit on it under this reason rather than paying for the round that proves it. **Enforced (advisory):** `validators/validate-run.py` `_detect_self_fix_recurrence` warns on that shape and names this stop reason.
449
- - `max-rounds-reached` — the rewrites are spent: the number of distinct rounds in `selfFixGroups` equals `selfFixMaxRounds`. Exit. Do not read this as `selfFixRoundsApplied == selfFixMaxRounds`. `selfFixRoundsApplied` is the **round number** the last self-fix was recorded on, not a count of rewrites (`validators/validate_session_conformance.py` `_self_fix_event_expectation` states this and counts the group rounds instead), and round 1 is the initial verification, so at the default `selfFixMaxRounds=1` the one rewrite lands on round 2 and the field reads `2` — the equality is never true and the exit it names is unreachable. The arithmetic that holds is `selfFixRoundsApplied - 1 == selfFixMaxRounds`. The same field also sets the batch ceiling: `_allowed_automatic_rounds` allows `2 + selfFixRoundsApplied` automatic plan-body batches, so the value is a round number on both sides.
450
+ - `max-rounds-reached` — the single automatic rewrite has been used. Exit to step 8. Count distinct `selfFixGroups[].round` values; `selfFixRoundsApplied` is the last verification round number, not the rewrite count. Extra verification batches before the rewrite do not increase the self-fix budget.
450
451
  - `cause-group-recurrence` — legacy read-only value for reports produced before activity contract v1. A new activity-contract-v1 run cannot emit it because there is no second automatic self-fix round in which a cause group can recur. **Enforced at the only writer:** `okstra plan-items complete-round --self-fix-stop-reason` accepts `all-resolved` / `no-progress` / `max-rounds-reached` and nothing else (`scripts/okstra_ctl/plan_items_cli.py:261`), and it is what sets `selfFixStopReason`, so the value has no way into a new report. A legacy report that already carries it is still an *exhausted* loop, so step 8 may promote its surviving items: `_SELF_FIX_EXHAUSTED_REASONS` admits this value alongside `no-progress` / `max-rounds-reached`. Refusing it there left such a report with no exit at all — the loop may not run again, and the item may not be promoted either.
451
452
  - `not-attempted` — the loop never ran because no item qualified.
452
453
  The `no-progress` and `max-rounds-reached` exits are what make the loop terminate; `selfFixMaxRounds` alone is the backstop.
453
454
  - a `majority-disagree` item with a majority of its deciding `DISAGREE` votes at `needs-user-input` is NOT a self-fix target — after correctness-critical precedence, it goes straight to the next step as `user-decision` rather than generic `noncritical-dissent`. **Enforced (promotion path):** `validators/validate-run.py` `_validate_self_fix_before_clarification` demands an exhausted self-fix budget only of `planner-fixable` majorities, so a `needs-user-input` majority is promotable with no self-fix round, and `_validate_plan_body_clarification_matching` fails it when it reaches no `blocks: approval` row. The classification *value* is authoring guidance per step 8: `scripts/okstra_ctl/approval_decisions.py` checks it against the classification enum and its allowed dispositions only — no validator recomputes it from the votes' `fixability`.
454
- 8. For every **in-scope execution** `majority-disagree` item **that remains after the self-fix loop** (items not resolved by self-fix, or with a `needs-user-input` majority from the start), and for every in-scope tie item `next-dispatch` returned under `user-decision` (§"even split" a criticless roster has no vote left to settle it), lead adds a row to `## 1. Clarification Items` with:
455
+ 8. **Resolve the remaining decisions without another automatic worker batch.** Run `okstra plan-items next-dispatch --state <state> --run-manifest <manifest>` after completing the self-fix verification. It returns `lead-decision` first for eligible items, then `user-decision` for the remainder. It does not schedule another critic or correction batch after the automatic rewrite. Enforcement: `plan_items.next_dispatch` and both state CLI callers.
456
+
457
+ For `lead-decision`, read the evidence and choose within the already agreed scope. Put the decision, why it is within lead authority, and source references in a Markdown file; run `okstra plan-items resolve-dissent --state <state> --item <P-id> --decision-file <file>`. This command records `leadDecision` and a visible `dissentLog` entry without changing votes. Gate status becomes `passed-with-dissent` when no other blocker remains. Enforcement: `plan_items_cli._resolve_dissent`, `validate-run._plan_item_decision_authority`, and `_lead_decision_applies` accept only current, verified, noncritical `b`/`c`/`e` judgements from at least two voting analysers after the one rewrite. A fact claim, `needs-user-input`, or non-result is ineligible. Changed content, scope or votes invalidate the decision's basis hash. The lead should cite the existing requirement or evidence granting authority; uncertainty about that authority goes to the user.
458
+
459
+ For `user-decision`, ask interactively immediately with the concrete choices and their consequences. Requirements changes, scope expansion, risk acceptance, user preferences, unavailable facts, and failed verification stay user-owned. Do not infer approval, replace failure with success, or use self-fix exhaustion as risk acceptance. Record an existing applicable answer instead of asking again. If no answer is available, retain an explicit pending row; elapsed time is not consent. These interaction rules use the approval-blocker protocol in `okstra-lead-contract.md` and the existing approval ledger validation.
460
+
461
+ For every remaining **in-scope execution** blocker or unresolved tie outside lead authority (including `needs-user-input` from the start), add a row to `## 1. Clarification Items` with:
455
462
  - do **not** promote an `observed` / `deferred` / `record` item. Those belong in `setAside`. A `Blocks=approval` C row for a frozen or unreached stage is how the clarification list grew while the next stage was already executable. **Enforced:** `validators/validate-run.py` `_validate_plan_body_clarification_matching` uses `_plan_item_gate_class` (after stage scope), not the raw vote class.
456
463
  - new `C-<N>` ID (numbering continues from any existing rows)
457
464
  - `Statement` summarising the disagreement and the worker breakage `<kind>`
@@ -100,7 +100,7 @@ A report-writer materialization without `--corrections` whose narrative already
100
100
  3. Run initial plan-body verification as round 1.
101
101
  4. Apply at most one automatic planner self-fix to the narrative. Skip this step when `gating` is `false`.
102
102
  5. Run targeted re-verification as round 2 when needed. Skip this step when `gating` is `false`.
103
- 6. Persist the completed `planBodyVerification` value in convergence state.
103
+ 6. Persist the completed `planBodyVerification` value in convergence state. Run `okstra plan-items next-dispatch`: after the single automatic self-fix, settle eligible judgements with `resolve-dissent`; ask the user immediately for decisions outside lead authority. Preserve dissent and do not restart the automatic loop. The exact procedure and enforced authority checks are in `prompts/lead/plan-body-verification.md` step 8.
104
104
  7. Complete the design-surface detector snapshot: `okstra design-snapshot --narrative <reportNarrativePath> --output <designPreparationPath>`, taking both paths from the run manifest. Nothing else writes that snapshot, and step 8 fails without it — `report_inputs._PLANNING_INPUT_FIELDS` lists `designPreparationPath` as a required planning input.
105
105
  8. Run Phase 7 report assembly.
106
106
 
@@ -93,7 +93,7 @@ profile document.
93
93
  - When a response is carried in, reconcile every prior `clarificationItems[]` row against new evidence and update its status to `resolved` or `obsolete` before issuing the next verdict. Schema-v1 compatibility Markdown may additionally render its conditional Section 0; the schema-v2 full reading copy records decisions under `## Clarification and User Decisions`.
94
94
  - **Supersession (BLOCKING).** Reconciling the `C-*` row is only half of incorporating an answer. An answer does not merely *add* a decision — it *invalidates* whatever the previous run wrote under the opposite assumption. Before issuing the next decision, walk the prior deliverable prose for every statement the answer makes false and **delete or rewrite it**, then record the retirement. Adding the new decision while leaving the contradicting sentence in place puts two opposite instructions for the same symbol in one document; the implementer must then guess which is live, and the next verification round correctly blocks on it. In `implementation-planning` this record is `implementationPlanning.supersessionLedger[]` — one entry per answered clarification, either `disposition: superseded` (with the retired statement, its replacement, and the sections revised) or `disposition: no-dependent-statement` (with a rationale). A plan built from a selected direction inherits the answers the option-selection record carried before it has any statement to retire, so those carried rows need no entry; the rows this plan itself raised and settled still do. **Enforced:** `validators/validate-run.py` `_validate_supersession_ledger` requires an entry per answered clarification, exempting the ledger's `carriedDecisions[]` ids on a selected-direction plan; whether the claim is *true* is what the §5.5.9 adversarial round tests.
95
95
  - Verdict Card data consistency (shared; schema-v1 Markdown keeps the legacy visible card):
96
- - The Card carries no verdict token — the token lives once, in `finalVerdict.verdictToken`, and every gate reads it there. `verdictCard.direction` byte-matches `finalVerdict.direction`; next-step routing agrees with `recommendedNextSteps[0]`. The full reading copy and human summary are derived from the data fields without repeating both visible sections. **Enforced in part:** the v3.0 schema's `verdictCard` is `additionalProperties: false` with no verdict-token property, so the token cannot be duplicated onto the Card, and `scripts/okstra_ctl/report_narrative.py` `_writer_owned_schema` applies the finished report's `$defs.Direction` enum to the narrative, rejecting an off-enum `direction` while the writer can still be re-run. The byte-match between the two `direction` fields is not compared by anything — assembly overwrites `nextStep` on both when the plan-body gate passes (`scripts/okstra_ctl/report_assembly.py:590-604`) but leaves `direction` as the writer wrote it.
96
+ - The Card carries no verdict token — the token lives once, in `finalVerdict.verdictToken`, and every gate reads it there. `verdictCard.direction` byte-matches `finalVerdict.direction`; next-step routing agrees with `recommendedNextSteps[0]`. The full reading copy and human summary are derived from the data fields without repeating both visible sections. **Enforced in part:** the v3.0 schema's `verdictCard` is `additionalProperties: false` with no verdict-token property, so the token cannot be duplicated onto the Card, and `scripts/okstra_ctl/report_narrative.py` `writer_owned_schema` applies the finished report's `$defs.Direction` enum to the narrative, rejecting an off-enum `direction` while the writer can still be re-run. The byte-match between the two `direction` fields is not compared by anything — assembly overwrites `nextStep` on both when the plan-body gate passes (`scripts/okstra_ctl/report_assembly.py:590-604`) but leaves `direction` as the writer wrote it.
97
97
  - Cross-worker traceability (shared — applies to every analysis worker output and to the lead's `## 6.` / `## 2.` tables in the final-report):
98
98
  - **Worker-side item IDs (free-form but unique within the worker).** Every row item in sections 1–5 (and any optional section 6) of an analysis worker's output MUST carry an item ID that is unique within that one worker's result file. The ID convention is the worker's choice — `F-001` / `F-002` per the suggested schema, `1.1` / `1.2` / `1.3` as Codex tends to use, or any other shape — but it MUST appear as the leading column of the row (for table-form items) or as a `[<ID>]` prefix (for bullet/numbered items). Workers that emit findings without IDs make cross-worker reconciliation impossible.
99
99
  - **Lead-side ID assignment + source preservation.** When the lead (or `report-writer-worker`) synthesises consensus, difference, or primary-evidence rows from worker outputs, the lead assigns a fresh `C-NNN` / `D-NNN` / `E-NNN` row ID. Each `sourceItems` field MUST list every contributing worker:item pair (e.g. `claude:F-001`, `codex:1.1`, `grok:F-3`, `kimi:2.4`) so an agent can trace the synthesised row to the worker result. Bare worker names are rejected. **Enforced:** `schemas/final-report-v2.0.schema.json` `$defs.SourceItem` pins each entry to `^[a-z][a-z-]*:[A-Za-z0-9._-]+$`, and `ConsensusRow` / `PrimaryEvidenceRow` require non-empty `sourceItems`.
@@ -235,7 +235,7 @@ roles:
235
235
  ```
236
236
 
237
237
  An `AGREE` note records the counterexample considered and its exclusion reason. If the judgement needs unavailable external material, record `verification-error`, not `DISAGREE`. **Enforced:** `validators/validate-run.py` `_validate_plan_item_extraction_completeness` compares the exact deterministic set, independently rejecting missing, unexpected, and duplicate plan-item IDs, including `P-Prep-*`.
238
- - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead runs a worker peer-review round on the persisted queue. Selected-direction plans begin with `P-Dir-1`; legacy candidate-comparison plans begin with `P-Opt-*`; both continue with the shared execution items. The fixed order remains initial verification → one planner self-fix → targeted re-verification → user gate. Gate recomputation, extraction completeness, approval-context reconciliation, and self-fix limits remain enforced by `validators/validate-run.py`; verdict details and dissent format are owned by `prompts/lead/plan-body-verification.md`.
238
+ - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead runs a worker peer-review round on the persisted queue. Selected-direction plans begin with `P-Dir-1`; legacy candidate-comparison plans begin with `P-Opt-*`; both continue with the shared execution items. The fixed order remains initial verification → one planner self-fix → targeted re-verification → lead decision or immediate user confirmation. Gate recomputation, extraction completeness, approval-context reconciliation, and self-fix limits remain enforced by `validators/validate-run.py`; verdict details and dissent format are owned by `prompts/lead/plan-body-verification.md`.
239
239
  - **Approval decision state.** The lead records active and carried decisions through `okstra approval-decision`. Every option carries `disposition`, one `reach`, and optional `scopeEffects`. A resolved decision names existing `A-NNN` checks; report assembly derives `approvalContext`, status, resolution, and reverse links.
240
240
  - `open → answered` when the raw user response is recorded
241
241
  - `answered → resolved` after the selected disposition is applied, when that work completed
@@ -106,6 +106,7 @@ def build_analysis_packet(
106
106
  stage_ledger_json: str = "",
107
107
  stage_ledger_notice: str = "",
108
108
  prior_planning_summary: str = "",
109
+ direct_work_text: str = "",
109
110
  ) -> str:
110
111
  """Return the primary compact input for Claude/Codex/Antigravity analysers.
111
112
 
@@ -146,6 +147,8 @@ def build_analysis_packet(
146
147
  )
147
148
  parts.extend(_group_context_block(group_human))
148
149
  parts.extend(_brief_block(brief_text))
150
+ if direct_work_text:
151
+ parts.extend(["", direct_work_text, ""])
149
152
  parts.extend(_group_memory_block(
150
153
  group_memory, _own_task_segment(task_key),
151
154
  "\n".join((brief_text, group_human, clarification_text, directive,
@@ -170,6 +173,47 @@ _FENCE_RE = re.compile(r"\A\s*(```|~~~)")
170
173
  _INDEX_PREAMBLE_LINES = 5
171
174
 
172
175
 
176
+ def reference_source_extracts(
177
+ packet_text: str, task_type: str, *, brief_text: str, profile_text: str,
178
+ reference_text: str, clarification_text: str,
179
+ ) -> str:
180
+ """합성 입력 안에 원문이 함께 있을 때 일치하는 발췌만 참조로 바꾼다."""
181
+ lines = packet_text.splitlines()
182
+ headings = _heading_lines(lines)
183
+ if headings and headings[0][1] == "## Section Index":
184
+ start = headings[0][0] - 2
185
+ end = start + _INDEX_PREAMBLE_LINES + len(headings) - 1
186
+ unindexed = "\n".join(lines[:start] + lines[end:]) + "\n"
187
+ # 생성기가 만든 목차임을 재구성으로 확인한다. 수정된 목차가 있으면
188
+ # 원문 전체를 유지해 내용 손실이나 잘못된 줄번호를 만들지 않는다.
189
+ if _with_section_index(unindexed) != packet_text:
190
+ return packet_text
191
+ lines = unindexed.splitlines()
192
+ blocks = (
193
+ ("Task brief", brief_text, _brief_block(brief_text)),
194
+ ("Analysis profile", profile_text, _profile_block(task_type, profile_text)),
195
+ ("Reference expectations", reference_text, _reference_block(reference_text)),
196
+ ("Clarification response", clarification_text, _clarification_block(clarification_text)),
197
+ )
198
+ changed = False
199
+ for label, source_text, parts in blocks:
200
+ expected = "\n".join(part.rstrip() for part in parts).strip()
201
+ if not source_text.strip() or not expected:
202
+ continue
203
+ heading = expected.splitlines()[0]
204
+ reference = f'{heading}\n\nRead the same extract in "Source: {label}" below.'
205
+ if len(reference) >= len(expected):
206
+ continue
207
+ for number, text in _heading_lines(lines):
208
+ start = number - 1
209
+ end = start + len(expected.splitlines())
210
+ if text == heading and "\n".join(lines[start:end]) == expected:
211
+ lines[start:end] = reference.splitlines()
212
+ changed = True
213
+ break
214
+ return "\n".join(lines).rstrip() + "\n" if changed else packet_text
215
+
216
+
173
217
  def _with_section_index(body: str) -> str:
174
218
  """Prefix the rendered packet with every section's line range.
175
219
 
@@ -384,6 +428,8 @@ def _group_memory_block(
384
428
  "- Detail: index-only; read the source if relevant or uncertain.",
385
429
  "",
386
430
  ])
431
+ if entry.source == "direct":
432
+ lines.extend(["- Source: direct work; no cross-verification performed for this record.", ""])
387
433
  return lines
388
434
 
389
435
 
@@ -0,0 +1,109 @@
1
+ """실행 없이 등록한 작업의 결과를 기존 작업·그룹 조회 경로에 연결한다."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from datetime import date
7
+ from pathlib import Path
8
+
9
+ from okstra_project import project_json_path, tasks_root
10
+ from okstra_project.dirs import TASK_CATALOG_RELATIVE
11
+
12
+ from . import group_context
13
+ from .brief_frontmatter import read_brief_frontmatter
14
+ from .ids import slugify_task_segment
15
+ from .json_boundary import load_owned_object, write_owned_object_atomic
16
+ from .paths import task_dir, task_manifest_file
17
+ from .render import render_task_catalog_discovery
18
+ from .run_context import dir_flock
19
+
20
+
21
+ def find_brief_tasks(project_root: Path, token: str, task_group: str) -> list[dict]:
22
+ project = load_owned_object(project_json_path(project_root), artifact="project config")
23
+ project_id = project.get("projectId", "")
24
+ if not project_id:
25
+ raise ValueError("project.json has no projectId")
26
+ matches = []
27
+ for folder in sorted(group_context.briefs_root(project_root).glob("*")):
28
+ if not folder.is_dir() or (task_group and folder.name != slugify_task_segment(task_group)):
29
+ continue
30
+ for brief in group_context.group_briefs(project_root, folder.name):
31
+ identities = (brief["brief_id"], brief["ticket_id"], folder.name)
32
+ if token.casefold() not in {str(value).casefold() for value in identities}:
33
+ continue
34
+ path = project_root / brief["brief"]
35
+ frontmatter = read_brief_frontmatter(path)
36
+ group = frontmatter.get("task-group") or folder.name
37
+ if slugify_task_segment(group) != folder.name:
38
+ raise ValueError(f"Brief task-group does not match its directory: {brief['brief']}")
39
+ task_id = slugify_task_segment(brief["brief_id"])
40
+ root = task_dir(project_root, slugify_task_segment(group), task_id)
41
+ matches.append({
42
+ "_briefVerified": True,
43
+ "schemaVersion": "1.0", "projectId": project_id,
44
+ "projectRoot": str(project_root), "taskGroup": group, "taskId": task_id,
45
+ "taskKey": f"{project_id}:{group}:{task_id}",
46
+ "taskGroupPathSegment": slugify_task_segment(group),
47
+ "taskIdPathSegment": task_id, "taskBriefPath": brief["brief"],
48
+ "taskRootPath": root.relative_to(project_root).as_posix(),
49
+ "taskManifestPath": task_manifest_file(root).relative_to(project_root).as_posix(),
50
+ })
51
+ return matches
52
+
53
+
54
+ def record_direct_work(project_root: Path, manifest_path: Path, manifest: dict) -> None:
55
+ """상태와 본문이 같은 재시도는 기존 결과를 재사용한다. 정본 포인터는 호출자가 쓴다."""
56
+ summary = str(manifest.get("workStatusNote") or "").strip()
57
+ if not summary:
58
+ raise ValueError("Direct completion requires --note or --note-file with the work and verification summary")
59
+ previous_path = manifest.get("latestWorkRecordPath", "")
60
+ if previous_path:
61
+ (project_root / previous_path).resolve().relative_to(manifest_path.parent.resolve())
62
+ previous = (
63
+ load_owned_object(project_root / previous_path, artifact="direct work record")
64
+ if previous_path else {}
65
+ )
66
+ if previous.get("summary") == summary and previous.get("statusUpdatedAt") == manifest["workStatusUpdatedAt"]:
67
+ return
68
+ record = {
69
+ "schemaVersion": "1.0", "taskKey": manifest["taskKey"], "source": "direct",
70
+ "summary": summary, "crossVerification": "not-performed",
71
+ "statusUpdatedAt": manifest["workStatusUpdatedAt"],
72
+ "previousRecordPath": previous_path,
73
+ }
74
+ identity = {key: value for key, value in record.items() if key != "statusUpdatedAt"}
75
+ digest = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
76
+ path = manifest_path.parent / "work-records" / f"{digest}.json"
77
+ if path.exists():
78
+ saved = load_owned_object(path, artifact="direct work record")
79
+ manifest["workStatusUpdatedAt"] = saved["statusUpdatedAt"]
80
+ manifest["updatedAt"] = saved["statusUpdatedAt"]
81
+ else:
82
+ write_owned_object_atomic(path, record, artifact="direct work record")
83
+ manifest["latestWorkRecordPath"] = path.relative_to(project_root).as_posix()
84
+
85
+
86
+ def publish_direct_work(project_root: Path, manifest: dict) -> Path | None:
87
+ """상태 저장 뒤 재실행해도 같은 그룹 항목을 갱신한다."""
88
+ catalog = project_root / TASK_CATALOG_RELATIVE
89
+ with dir_flock(catalog.parent, ".task-catalog.lock"):
90
+ render_task_catalog_discovery(str(catalog), {
91
+ "PROJECT_ROOT": str(project_root), "OKSTRA_TASKS_ROOT": str(tasks_root(project_root)),
92
+ "PROJECT_ID": manifest["projectId"], "TASK_KEY": manifest["taskKey"],
93
+ "RUN_TIMESTAMP_ISO": manifest["workStatusUpdatedAt"],
94
+ })
95
+ if not manifest.get("latestWorkRecordPath") or manifest["workStatus"] != "done":
96
+ return group_context.refresh_group_queue(project_root, manifest["taskGroup"])
97
+ record = load_owned_object(
98
+ project_root / manifest["latestWorkRecordPath"], artifact="direct work record"
99
+ )
100
+ entry = group_context.MemoryEntry(
101
+ task_id=manifest["taskId"], task_type="", seq="", next_phase="",
102
+ date=record["statusUpdatedAt"][:10], headline=" ".join(record["summary"].split())[:group_context.HEADLINE_MAX],
103
+ decisions=(), follow_ups=(), record=manifest["latestWorkRecordPath"],
104
+ source="direct",
105
+ )
106
+ target, _ = group_context.record_task_memory(
107
+ project_root, manifest["taskGroup"], entry, today=date.today()
108
+ )
109
+ return target
@@ -102,6 +102,7 @@ class MemoryEntry:
102
102
  follow_ups: tuple[str, ...]
103
103
  record: str
104
104
  watch_out: tuple[str, ...] = ()
105
+ source: str = "run"
105
106
 
106
107
 
107
108
  def memory_entry_from_record(
@@ -198,6 +199,8 @@ def parse_memory_entries(region: str) -> list[MemoryEntry]:
198
199
  task_type=match.group("type"), seq=match.group("seq"),
199
200
  date=match.group("date"), next_phase=match.group("next").strip(),
200
201
  )
202
+ elif line.startswith("- direct: "):
203
+ current.update(source="direct", date=line[len("- direct: "):].strip())
201
204
  elif line.startswith("- headline: "):
202
205
  current["headline"] = line[len("- headline: "):].strip()
203
206
  elif line == "- decisions:":
@@ -227,6 +230,7 @@ def _entry_from_fields(fields: Mapping[str, Any]) -> MemoryEntry:
227
230
  follow_ups=tuple(fields.get("follow_ups", [])),
228
231
  record="" if record in _NONE_MARKERS else record,
229
232
  watch_out=tuple(fields.get("watch_out", [])),
233
+ source=fields.get("source", "run"),
230
234
  )
231
235
 
232
236
 
@@ -238,9 +242,12 @@ def render_memory_entries(entries: list[MemoryEntry]) -> str:
238
242
  for entry in entries:
239
243
  lines = [
240
244
  f"### {entry.task_id}",
241
- f"- latest: {entry.task_type} #{entry.seq} · {entry.date} · next: {entry.next_phase or '_(none)_'}",
245
+ (f"- direct: {entry.date}" if entry.source == "direct" else
246
+ f"- latest: {entry.task_type} #{entry.seq} · {entry.date} · next: {entry.next_phase or '_(none)_'}"),
242
247
  f"- headline: {entry.headline or '_(none)_'}",
243
248
  ]
249
+ if entry.source == "direct":
250
+ lines.append("- Verification: direct work; no cross-verification performed for this record.")
244
251
  if entry.decisions:
245
252
  lines.append("- decisions:")
246
253
  lines.extend(f" - {item}" for item in entry.decisions)
@@ -468,6 +475,12 @@ def catalog_progress(project_root: Path, task_group: str) -> dict[str, tuple[str
468
475
  if str(entry.get("workStatus") or "") == WORK_STATUS_DONE:
469
476
  status = QUEUE_DONE
470
477
  progress = f"{progress} · marked done" if progress else "marked done"
478
+ elif entry.get("workStatus") == "todo":
479
+ status = QUEUE_NOT_STARTED
480
+ elif entry.get("workStatus") in ("in-progress", "blocked"):
481
+ status = QUEUE_IN_PROGRESS
482
+ if entry.get("latestWorkRecordPath"):
483
+ progress = f"{progress} · direct work recorded" if progress else "direct work recorded"
471
484
  out[task_id] = (status, progress)
472
485
  return out
473
486
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import json
11
11
  from pathlib import Path
12
12
  from typing import Optional
13
+ from okstra_project import list_project_tasks
13
14
 
14
15
  from .json_boundary import JsonBoundaryError, load_owned_object
15
16
 
@@ -64,3 +65,31 @@ def related_tasks_bullets(items: list[str]) -> str:
64
65
 
65
66
  def related_tasks_inline(items: list[str]) -> str:
66
67
  return ", ".join(items) if items else "None"
68
+
69
+
70
+ def direct_work_context(project_root: Path, task_key: str, related: list[str]) -> str:
71
+ """자기 작업과 다른 그룹의 명시적 관련 작업 결과를 준비 입력에 싣는다."""
72
+ own_group = task_key.split(":")[1].casefold()
73
+ wanted = {value.casefold() for value in related}
74
+ blocks = []
75
+ for task in list_project_tasks(project_root):
76
+ key = task["taskKey"]
77
+ is_own = key.casefold() == task_key.casefold()
78
+ is_related = key.casefold() in wanted or str(task.get("taskId", "")).casefold() in wanted
79
+ if not is_own and (not is_related or str(task.get("taskGroup", "")).casefold() == own_group):
80
+ continue
81
+ record_path = task.get("latestWorkRecordPath")
82
+ if not record_path:
83
+ continue
84
+ record_file = project_root / record_path
85
+ record_file.resolve().relative_to((project_root / ".okstra").resolve())
86
+ record = load_owned_object(record_file, artifact="direct work record")
87
+ blocks.extend([
88
+ f"### {key}", f"- Current work status: {task.get('workStatus', '')}",
89
+ "- Source: direct work; no cross-verification performed for this record.",
90
+ f"- Summary: {' '.join(record['summary'].split())[:400]}",
91
+ f"- Record: `{record_path}`", "",
92
+ ])
93
+ if not blocks:
94
+ return ""
95
+ return "\n".join(["## Direct Work Context", "", *blocks])
@@ -114,6 +114,7 @@ def _status_overview_text(rows: list[dict[str, object]]) -> str:
114
114
  _line("Latest report", row.get("latestReportRecordPath")),
115
115
  _line("Latest resume command", row.get("latestResumeCommandPath")),
116
116
  _line("Work status", row.get("workStatus")),
117
+ _line("Direct work record", row.get("latestWorkRecordPath")),
117
118
  ))
118
119
  return "# Okstra Status Input\n\n" + "".join(blocks)
119
120
 
@@ -106,6 +106,7 @@ def _overview_rows(
106
106
  "latestReportRecordPath": manifest.get("latestReportRecordPath") or latest_run.get("reportRecordPath"),
107
107
  "latestResumeCommandPath": latest_run.get("resumeCommandPath") or manifest.get("latestResumeCommandPath"),
108
108
  "workStatus": manifest.get("workStatus"),
109
+ "latestWorkRecordPath": manifest.get("latestWorkRecordPath"),
109
110
  "lastRun": latest_run.get("runTimestamp"),
110
111
  }
111
112
  if task_type and row["taskType"] != task_type:
@@ -310,6 +311,7 @@ def render_status_input(
310
311
  + _line("Work status", manifest.get("workStatus"))
311
312
  + _line("Work status updated at", manifest.get("workStatusUpdatedAt"))
312
313
  + _line("Work status note", manifest.get("workStatusNote"))
314
+ + _line("Direct work record", manifest.get("latestWorkRecordPath"))
313
315
  + _line("Latest report", manifest.get("latestReportRecordPath"))
314
316
  + _line("Latest resume command", manifest.get("latestResumeCommandPath"))
315
317
  + _line("History timeline", manifest.get("historyTimelinePath"))
@@ -363,6 +365,8 @@ def render_recap_input(project_root: Path, task_ref: str) -> str:
363
365
  "# Okstra Recap Input\n\n",
364
366
  _line("Task key", recap.get("taskKey")),
365
367
  _line("Run count", recap.get("runCount")),
368
+ _line("Work status", recap.get("workStatus")),
369
+ _line("Direct work record", recap.get("latestWorkRecordPath")),
366
370
  ]
367
371
  transitions = recap.get("transitions")
368
372
  for transition in transitions if isinstance(transitions, list) else []:
@@ -426,6 +430,8 @@ def render_group_recap_input(project_root: Path, task_group: str) -> str:
426
430
  _line("Phase state", task.get("currentPhaseState")),
427
431
  _line("Latest run status", task.get("latestRunStatus")),
428
432
  _line("Work status", task.get("workStatus")),
433
+ _line("Direct work record", task.get("latestWorkRecordPath")),
434
+ _line("Memory source", memory.get("source")),
429
435
  _line("Run count", task.get("runCount")),
430
436
  _line("Next phase", pointer.get("phase")),
431
437
  _line("Next phase status", pointer.get("status")),