okstra 0.214.1 → 0.216.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 (118) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-registry.d.mts +14 -0
  3. package/dist/cli-registry.mjs +12 -0
  4. package/dist/cli-registry.mjs.map +1 -1
  5. package/dist/commands/chat/chat.mjs +2 -56
  6. package/dist/commands/chat/chat.mjs.map +1 -1
  7. package/dist/commands/execute/render-bundle.mjs +5 -2
  8. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  9. package/dist/commands/execute/wizard.mjs +6 -0
  10. package/dist/commands/execute/wizard.mjs.map +1 -1
  11. package/dist/commands/memory/memory.mjs +57 -6
  12. package/dist/commands/memory/memory.mjs.map +1 -1
  13. package/dist/lib/file-lock.d.mts +1 -0
  14. package/dist/lib/file-lock.mjs +60 -0
  15. package/dist/lib/file-lock.mjs.map +1 -0
  16. package/dist/lib/python-command.mjs +11 -1
  17. package/dist/lib/python-command.mjs.map +1 -1
  18. package/dist/lib/skill-catalog.mjs +1 -0
  19. package/dist/lib/skill-catalog.mjs.map +1 -1
  20. package/dist/lib/types.d.mts +1 -1
  21. package/docs/architecture.md +2 -2
  22. package/docs/cli.md +36 -14
  23. package/docs/project-structure-overview.md +6 -3
  24. package/package.json +1 -1
  25. package/runtime/BUILD.json +2 -2
  26. package/runtime/bin/lib/okstra/cli.sh +9 -2
  27. package/runtime/bin/lib/okstra/globals.sh +2 -0
  28. package/runtime/bin/lib/okstra/interactive.sh +8 -2
  29. package/runtime/bin/lib/okstra/usage.sh +3 -1
  30. package/runtime/bin/okstra.sh +7 -1
  31. package/runtime/prompts/duties/discussion-worker.json +32 -0
  32. package/runtime/prompts/lead/convergence.md +58 -136
  33. package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
  34. package/runtime/prompts/profiles/_common-contract.md +1 -1
  35. package/runtime/prompts/wizard/prompts.ko.json +14 -1
  36. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
  37. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
  38. package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
  39. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
  40. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
  41. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
  42. package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
  43. package/runtime/python/okstra_ctl/consumers.py +8 -2
  44. package/runtime/python/okstra_ctl/convergence.py +143 -29
  45. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
  46. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
  47. package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
  48. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
  49. package/runtime/python/okstra_ctl/convergence_store.py +11 -4
  50. package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
  51. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  52. package/runtime/python/okstra_ctl/manager_cli.py +27 -1
  53. package/runtime/python/okstra_ctl/manager_launch.py +44 -4
  54. package/runtime/python/okstra_ctl/manager_sync.py +14 -1
  55. package/runtime/python/okstra_ctl/manager_view.py +53 -1
  56. package/runtime/python/okstra_ctl/next_phase.py +1 -0
  57. package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
  58. package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
  59. package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
  60. package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
  61. package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
  62. package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
  63. package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
  64. package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
  65. package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
  66. package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
  67. package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
  68. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
  69. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
  70. package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
  71. package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
  72. package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
  73. package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
  74. package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
  75. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
  76. package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
  77. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
  78. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
  79. package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
  80. package/runtime/python/okstra_ctl/render.py +7 -20
  81. package/runtime/python/okstra_ctl/render_final_report.py +2 -1
  82. package/runtime/python/okstra_ctl/report_assembly.py +22 -0
  83. package/runtime/python/okstra_ctl/report_contract.py +2 -0
  84. package/runtime/python/okstra_ctl/report_narrative.py +13 -4
  85. package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
  86. package/runtime/python/okstra_ctl/run.py +40 -7
  87. package/runtime/python/okstra_ctl/stage_close.py +46 -11
  88. package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
  89. package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
  90. package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
  91. package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
  92. package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
  93. package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
  94. package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
  95. package/runtime/python/okstra_ctl/wizard/render.py +4 -1
  96. package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
  97. package/runtime/python/okstra_ctl/wizard/state.py +11 -1
  98. package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
  99. package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
  100. package/runtime/python/okstra_token_usage/pricing.py +2 -0
  101. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  102. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  103. package/runtime/schemas/final-report-v3.0.schema.json +90 -1
  104. package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
  105. package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
  106. package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
  107. package/runtime/skills/_fragments/wizard-preflight.md +75 -0
  108. package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
  109. package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
  110. package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
  111. package/runtime/skills/okstra-discussion/SKILL.md +347 -0
  112. package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
  113. package/runtime/skills/okstra-run/SKILL.md +22 -2
  114. package/runtime/templates/manager/view.template.html +3 -0
  115. package/runtime/templates/reports/html/i18n/en.json +5 -0
  116. package/runtime/templates/reports/html/i18n/ko.json +5 -0
  117. package/runtime/validators/validate-brief.py +3 -2
  118. package/runtime/validators/validate-run.py +4 -6
@@ -0,0 +1,512 @@
1
+ """`okstra discussion` — 진행자가 토론 기록을 쓰고 읽는 유일한 명령."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from okstra_project import (
10
+ ResolverError,
11
+ StateError,
12
+ resolve_project_root,
13
+ resolve_task_identity,
14
+ )
15
+
16
+ from . import followup, memory, workflow
17
+ from .store import (
18
+ COMMON,
19
+ SCOPE_KINDS,
20
+ USER,
21
+ DiscussionStore,
22
+ DiscussionStoreError,
23
+ append_record,
24
+ confirm_completion,
25
+ read_draft,
26
+ read_revision,
27
+ render_draft_text,
28
+ render_full_record,
29
+ resolve_source_run,
30
+ )
31
+
32
+ _CLI_EPILOG = r"""Usage:
33
+ okstra discussion confirm-subject <task-key> --subject "<what>" --question "<to settle>" --run-manifest <path>
34
+ okstra discussion ask <task-key> --author <leader|worker-id> --text "<question for the user>" --run-manifest <path>
35
+ okstra discussion address <task-key> --to <worker-id> --text "<the user's question>" --run-manifest <path>
36
+ okstra discussion answer <task-key> --question <Q-NNNN> --author <user|worker-id> --text "<answer>" --run-manifest <path>
37
+ okstra discussion decide <task-key> --text "<the user's decision>" [--question <Q-NNNN>] --run-manifest <path>
38
+ okstra discussion add-material <task-key> --kind <evidence|alternative> --author <user|leader|worker-id> --text "<what>" --run-manifest <path>
39
+ okstra discussion draft <task-key>
40
+ okstra discussion status <task-key>
41
+ okstra discussion cycle-open <task-key> --run-manifest <path>
42
+ okstra discussion settle <task-key> --run-manifest <path>
43
+ okstra discussion recover <task-key> --failure <X-NNNN> --choice <retry|replace|reduce> --run-manifest <path>
44
+ okstra discussion cycle-close <task-key> --run-manifest <path>
45
+ okstra discussion question-prompt <task-key> --question <Q-NNNN> --run-manifest <path>
46
+ okstra discussion collect-answer <task-key> --question <Q-NNNN> --run-manifest <path>
47
+ okstra discussion add-requirement <task-key> --text "<requirement>" [--applies-to <common|follow-up label>] --author <user|leader|worker-id> --run-manifest <path>
48
+ okstra discussion add-scope <task-key> --kind <in-scope|excluded> --text "<what>" --author <user|leader|worker-id> --run-manifest <path>
49
+ okstra discussion withdraw <task-key> --item <N-NNNN|B-NNNN> --reason "<why the user drops it>" --run-manifest <path>
50
+ okstra discussion confirm-completion <task-key> --draft-version <sha256:...> --direction "<the user's chosen direction>" --risk "<the risks the user accepts>" --run-manifest <path>
51
+ okstra discussion reopen <task-key> --revision <task-key/revision-NNN> --reason "<why it changes>" --run-manifest <path>
52
+ okstra discussion revisions <task-key>
53
+ okstra discussion revision-show <task-key> --revision <task-key/revision-NNN>
54
+ okstra discussion record <task-key>
55
+ okstra discussion memory-sync <task-key> [--revision <task-key/revision-NNN> [--summary-file <path>]]
56
+ okstra discussion follow-up-brief <task-key> --revision <task-key/revision-NNN> --follow-up <label> --target-task <task-key> [--target-project-root <dir>] [--end-state <N-NNNN>=<EB|PB|EO>[:<how to observe>]]... [--replace]
57
+ okstra discussion links <task-key>
58
+ okstra discussion impact <task-key> --from <revision-id> --to <revision-id> [--follow-up <label>]
59
+ okstra discussion apply <task-key> --revision <latest revision-id> --target-task <task-key> --choice <adopt|keep> [--end-state ...]... [--replace]
60
+ okstra discussion complete-amendment <task-key> --revision <revision-id> --target-task <task-key> [--end-state ...]... [--replace]
61
+
62
+ Records go to <task-root>/discussion/records.jsonl under a directory lock, and
63
+ the draft read model is rebuilt after every record. --run-manifest names the
64
+ discussion run the record comes from; a manifest of another task, or of another
65
+ task type, is refused. Authors must be `leader` or a worker of that run; a
66
+ question asked with `ask` goes to the user, a question asked with `address`
67
+ comes from the user and goes to the named worker, and only its addressee may
68
+ answer it. Sending the same record again returns the stored record
69
+ (`created: false`) instead of adding a second one.
70
+
71
+ `draft` prints the fixed-text read model every participant reads: the confirmed
72
+ subject and question, start inputs, user decisions, questions waiting for the
73
+ user or for a participant, and every answered question with its answers.
74
+
75
+ `status` prints the phase (framing, ready-for-research, researching,
76
+ awaiting-recovery, awaiting-new-run, awaiting-user, awaiting-participant,
77
+ awaiting-choice, completed), the draft version, the published revisions, the
78
+ one question to put to the user next, unrecovered failures and the remaining
79
+ disagreements.
80
+
81
+ One research and refutation cycle runs per discussion run. `cycle-open` either
82
+ writes the research instruction every analyser receives (action `dispatch`) or
83
+ says why no research is dispatched: `await-user`, `await-participant`,
84
+ `await-recovery`, `present-options` (the input references — start inputs,
85
+ subject, answers, materials — equal the last closed cycle's), `prepare-run` (a
86
+ continuation run with `--discussion-start <task-key>` holds the next cycle), or
87
+ `completed` (the user completed the discussion). `settle` records each analyser's completed result and each failure
88
+ the dispatcher left after its retry loop; `recover` records the user's choice
89
+ for one failure. `cycle-close` imports the run's finalized convergence state as
90
+ claims with their authors and verdicts and ends that verification cycle; it
91
+ never completes the discussion. `question-prompt` writes the instruction and the
92
+ `agent-prompt materialize --batch` file for one direct question, and
93
+ `collect-answer` records the addressee's whole result file as the answer.
94
+
95
+ Completion belongs to the user. `add-requirement` and `add-scope` record the
96
+ requirements (common, or for one follow-up) and the in-scope and excluded
97
+ items of the current draft; adding an item the draft already holds returns it.
98
+ After `reopen`, the draft starts with the reopened revision's requirements and
99
+ scope, each under its original record id and marked with the revision it was
100
+ carried from. `withdraw` removes one carried or added item with the user's
101
+ reason; replacing an item is `withdraw` followed by `add-requirement` or
102
+ `add-scope`. `confirm-completion` binds the user's direction
103
+ choice and risk confirmation to the draft version `status` and `draft` print;
104
+ it is refused when that version is stale, or while the subject, a requirement,
105
+ an in-scope or excluded entry, a closed research cycle, a user or participant
106
+ answer or a failure choice is missing. It then publishes one immutable
107
+ revision, <task-root>/discussion/revisions/revision-NNN.json, whose id is the
108
+ task key and the completion sequence; resending the same confirmation returns
109
+ the published revision. Remaining disagreements, unverified scope and the
110
+ reduced roster are carried, not blocking. A completed discussion takes no other
111
+ record until `reopen` names the latest revision and the reason for change; the
112
+ next revision then carries that reference and reason, lists under
113
+ `itemChanges` what it carried, added and withdrawn, and earlier revisions
114
+ stay byte-for-byte readable with `revision-show`. `revisions` lists the
115
+ published revisions, `record` prints the draft read model followed by every
116
+ research result in full. Finalizing a run never confirms completion.
117
+
118
+ Every published revision is registered in the Memory Book after publication,
119
+ without a prompt: `okstra memory add --source-key discussion:<revision-id>`
120
+ stores a summary of its decisions, disagreements, risks and unverified scope
121
+ with the revision id, content digest, project and task — never the discussion
122
+ record itself. The summary and the registration state live in
123
+ <task-root>/discussion/memory/. A failed registration never undoes the
124
+ revision: it stays `pending`, `status` lists it under memoryRegistration, and
125
+ `memory-sync` retries it with the same source key; registering the same
126
+ revision again returns its existing entry. A summary the Memory Book refuses as
127
+ a likely secret is `refused` and is not retried until --summary-file supplies a
128
+ changed summary for that --revision.
129
+
130
+ Follow-ups run outside a discussion run, at the user's request. `follow-up-brief`
131
+ writes the brief of one follow-up task from one revision: the common
132
+ requirements and those that apply to --follow-up, the confirmed subject,
133
+ direction, scope, exclusions, disagreements and unverified scope, each naming
134
+ its record in the revision. --end-state maps a requirement to an Expected
135
+ Behavior, Preserved Behavior or Expected Outcome item; an observation method the
136
+ revision does not carry is new meaning, so the brief stays
137
+ `reporter-confirmations: pending` with an `intent-check:` row until the user
138
+ confirms it (okstra-brief-gen Step 6.5). The brief lands at
139
+ <target-project-root>/.okstra/briefs/<task-group>/<task-id>-<label>.md, passes
140
+ validators/validate-brief.py, recommends `requirements-discovery`, and pins the
141
+ discussion, the revision id, its content digest and the follow-up's requirement
142
+ ids in its frontmatter; prepare copies that pin into the run inputs as
143
+ `discussionRevisionRef`. The brief is the target task's own record of the
144
+ revision it uses; <task-root>/discussion/links.jsonl only remembers where each
145
+ target is. The brief is written first and the link after it, so resending an
146
+ interrupted command records the link once. A brief this command did not write
147
+ last is not replaced without --replace.
148
+
149
+ `links` re-reads every linked target: `current`, `needs-impact-review` (a newer
150
+ revision exists and the user has not chosen), `kept`, `amendment-pending`, or
151
+ `unknown` when the target cannot be read; an `amendment-pending` target names the
152
+ approved amended plan, or what is still missing. `impact` prints what changed between
153
+ two revisions, for one follow-up when --follow-up is given. `apply` records the
154
+ user's choice for one target and the latest revision: `keep` changes nothing,
155
+ `adopt` rewrites the brief of a target without an implementation-planning plan,
156
+ and routes a target with a plan to the plan amendment path without touching its
157
+ plan, brief or source. Resending the same choice returns the stored record; a
158
+ different choice for the same target and revision is refused.
159
+ `complete-amendment` finishes a routed adoption once the target has an
160
+ implementation-planning report newer than the one it had when the adoption was
161
+ routed and the user approved it (`frontmatter.approved: true`): it rewrites the
162
+ brief to pin the revision under the same --replace rule and records the plan
163
+ report it relied on. Without that report it is refused and names what is
164
+ missing; it never edits or approves a plan, and a resend returns the record.
165
+
166
+ Output: JSON { ok, created, record, pendingForUser, pendingForParticipants } for
167
+ the record commands, JSON { ok, ... } for the cycle, status, revisions,
168
+ memory-sync and follow-up commands, a `memory` field with the registration
169
+ state on confirm-completion, plain text for `draft` and `record`, and the revision file's own
170
+ bytes for `revision-show`. Exit 1 on a refusal, 2 when PROJECT_ROOT or the
171
+ task cannot be resolved.
172
+ """
173
+
174
+
175
+ def _add_common(parser: argparse.ArgumentParser) -> None:
176
+ parser.add_argument("task_key", metavar="task-key", help="project-id:task-group:task-id")
177
+ parser.add_argument("--project-root", "--project", default="",
178
+ help="use this directory as PROJECT_ROOT")
179
+ parser.add_argument("--cwd", default=".", help="resolve PROJECT_ROOT starting from here")
180
+
181
+
182
+ def _add_run(parser: argparse.ArgumentParser) -> None:
183
+ parser.add_argument("--run-manifest", required=True,
184
+ help="the discussion run manifest the record comes from")
185
+
186
+
187
+ def _add_target(parser: argparse.ArgumentParser) -> None:
188
+ parser.add_argument("--target-task", required=True, help="the follow-up task key")
189
+ parser.add_argument("--end-state", action="append", default=[],
190
+ help="<N-NNNN>=<EB|PB|EO>[:<how to observe it>]; repeat per requirement")
191
+ parser.add_argument("--replace", action="store_true",
192
+ help="replace a brief this command did not write last")
193
+
194
+
195
+ def _follow_up(store: DiscussionStore, args: argparse.Namespace) -> dict:
196
+ if args.command == "links":
197
+ return followup.links(store)
198
+ if args.command == "impact":
199
+ return followup.impact(store, from_revision=args.from_revision, to_revision=args.to_revision,
200
+ label=args.follow_up)
201
+ end_states = [followup.parse_end_state(value) for value in args.end_state]
202
+ if args.command == "complete-amendment":
203
+ return followup.complete_amendment(store, revision_id=args.revision, target_key=args.target_task,
204
+ end_states=end_states, replace=args.replace)
205
+ if args.command == "apply":
206
+ return followup.apply_revision(store, revision_id=args.revision, target_key=args.target_task,
207
+ choice=args.choice, end_states=end_states, replace=args.replace)
208
+ target_root = Path(args.target_project_root) if args.target_project_root else store.project_root
209
+ return followup.create_follow_up(
210
+ store, revision_id=args.revision, label=args.follow_up,
211
+ target=followup.target_of(args.target_task, target_root), end_states=end_states, replace=args.replace,
212
+ )
213
+
214
+
215
+ _FOLLOW_UP_COMMANDS = frozenset({"follow-up-brief", "links", "impact", "apply", "complete-amendment"})
216
+
217
+
218
+ def build_parser() -> argparse.ArgumentParser:
219
+ parser = argparse.ArgumentParser(
220
+ prog="okstra discussion",
221
+ description="Record and read a discussion task's questions, answers and decisions, drive its research cycles and publish the user's completed revisions.",
222
+ epilog=_CLI_EPILOG,
223
+ formatter_class=argparse.RawDescriptionHelpFormatter,
224
+ )
225
+ parser.add_argument("--okstra-cli", default="", help=argparse.SUPPRESS)
226
+ sub = parser.add_subparsers(dest="command", required=True)
227
+
228
+ subject = sub.add_parser("confirm-subject", help="record the subject and question the user confirmed")
229
+ _add_common(subject)
230
+ subject.add_argument("--subject", required=True)
231
+ subject.add_argument("--question", required=True)
232
+ _add_run(subject)
233
+
234
+ ask = sub.add_parser("ask", help="register a question for the user")
235
+ _add_common(ask)
236
+ ask.add_argument("--author", required=True, help="`leader` or a worker id of the run")
237
+ ask.add_argument("--text", required=True)
238
+ _add_run(ask)
239
+
240
+ address = sub.add_parser("address", help="register the user's question to one participant")
241
+ _add_common(address)
242
+ address.add_argument("--to", required=True, help="the worker id the user addresses")
243
+ address.add_argument("--text", required=True)
244
+ _add_run(address)
245
+
246
+ answer = sub.add_parser("answer", help="record the answer to a question")
247
+ _add_common(answer)
248
+ answer.add_argument("--question", required=True, help="the question id (Q-NNNN)")
249
+ answer.add_argument("--author", required=True, help="`user` or the addressed worker id")
250
+ answer.add_argument("--text", required=True)
251
+ _add_run(answer)
252
+
253
+ decide = sub.add_parser("decide", help="record a user decision")
254
+ _add_common(decide)
255
+ decide.add_argument("--text", required=True)
256
+ decide.add_argument("--question", default="", help="the question the decision settles")
257
+ _add_run(decide)
258
+
259
+ material = sub.add_parser("add-material", help="record new evidence or a new alternative")
260
+ _add_common(material)
261
+ material.add_argument("--kind", required=True, choices=("evidence", "alternative"))
262
+ material.add_argument("--author", required=True, help="`user`, `leader` or a worker id of the run")
263
+ material.add_argument("--text", required=True)
264
+ _add_run(material)
265
+
266
+ draft = sub.add_parser("draft", help="print the draft read model as fixed text")
267
+ _add_common(draft)
268
+
269
+ state = sub.add_parser("status", help="print the phase and the next question for the user")
270
+ _add_common(state)
271
+
272
+ for name, help_text in (
273
+ ("cycle-open", "open this run's research cycle or say why none is dispatched"),
274
+ ("settle", "record the research results and failures of this run's cycle"),
275
+ ("cycle-close", "end this run's verification cycle from its convergence final state"),
276
+ ):
277
+ command = sub.add_parser(name, help=help_text)
278
+ _add_common(command)
279
+ _add_run(command)
280
+
281
+ recover = sub.add_parser("recover", help="record the user's choice for one failure")
282
+ _add_common(recover)
283
+ recover.add_argument("--failure", required=True, help="the failure id (X-NNNN)")
284
+ recover.add_argument("--choice", required=True, choices=workflow.CHOICES)
285
+ _add_run(recover)
286
+
287
+ requirement = sub.add_parser("add-requirement", help="record a requirement of the current draft")
288
+ _add_common(requirement)
289
+ requirement.add_argument("--text", required=True)
290
+ requirement.add_argument("--applies-to", default=COMMON,
291
+ help="`common`, or the follow-up the requirement belongs to")
292
+ requirement.add_argument("--author", required=True, help="`user`, `leader` or a worker id of the run")
293
+ _add_run(requirement)
294
+
295
+ scope = sub.add_parser("add-scope", help="record an in-scope or excluded item of the current draft")
296
+ _add_common(scope)
297
+ scope.add_argument("--kind", required=True, choices=SCOPE_KINDS)
298
+ scope.add_argument("--text", required=True)
299
+ scope.add_argument("--author", required=True, help="`user`, `leader` or a worker id of the run")
300
+ _add_run(scope)
301
+
302
+ withdraw = sub.add_parser("withdraw", help="withdraw one requirement or scope item from the current draft")
303
+ _add_common(withdraw)
304
+ withdraw.add_argument("--item", required=True, help="the requirement or scope record id (N-NNNN or B-NNNN)")
305
+ withdraw.add_argument("--reason", required=True, help="why the user withdraws it")
306
+ _add_run(withdraw)
307
+
308
+ confirm = sub.add_parser("confirm-completion",
309
+ help="record the user's completion confirmation and publish a revision")
310
+ _add_common(confirm)
311
+ confirm.add_argument("--draft-version", required=True,
312
+ help="the draft version the user confirmed (from `status` or `draft`)")
313
+ confirm.add_argument("--direction", required=True, help="the direction the user chose")
314
+ confirm.add_argument("--risk", required=True,
315
+ help="the remaining disagreements and risks the user confirmed")
316
+ _add_run(confirm)
317
+
318
+ reopen = sub.add_parser("reopen", help="open a new draft on top of the latest revision")
319
+ _add_common(reopen)
320
+ reopen.add_argument("--revision", required=True, help="the latest revision id")
321
+ reopen.add_argument("--reason", required=True, help="why the outcome changes")
322
+ _add_run(reopen)
323
+
324
+ revisions = sub.add_parser("revisions", help="list the published revisions")
325
+ _add_common(revisions)
326
+
327
+ show = sub.add_parser("revision-show", help="print one published revision byte-for-byte")
328
+ _add_common(show)
329
+ show.add_argument("--revision", required=True, help="the revision id (<task-key>/revision-NNN)")
330
+
331
+ record = sub.add_parser("record", help="print the whole discussion record as fixed text")
332
+ _add_common(record)
333
+
334
+ sync = sub.add_parser("memory-sync", help="register published revisions the Memory Book does not hold yet")
335
+ _add_common(sync)
336
+ sync.add_argument("--revision", default="", help="only this revision")
337
+ sync.add_argument("--summary-file", default="",
338
+ help="a changed summary for --revision, after the Memory Book refused the last one")
339
+
340
+ brief = sub.add_parser("follow-up-brief", help="write one follow-up task's brief from a revision")
341
+ _add_common(brief)
342
+ brief.add_argument("--revision", required=True, help="the revision id the follow-up uses")
343
+ brief.add_argument("--follow-up", required=True, help="the follow-up label the requirements apply to")
344
+ _add_target(brief)
345
+ brief.add_argument("--target-project-root", default="",
346
+ help="the follow-up's project root (default: this discussion's project)")
347
+
348
+ links = sub.add_parser("links", help="list the linked follow-ups and their impact-review state")
349
+ _add_common(links)
350
+
351
+ changes = sub.add_parser("impact", help="print what changed between two revisions")
352
+ _add_common(changes)
353
+ changes.add_argument("--from", dest="from_revision", required=True, help="the earlier revision id")
354
+ changes.add_argument("--to", dest="to_revision", required=True, help="the later revision id")
355
+ changes.add_argument("--follow-up", default="", help="only the requirements of this follow-up")
356
+
357
+ adopt = sub.add_parser("apply", help="record the user's choice to adopt or keep the latest revision")
358
+ _add_common(adopt)
359
+ adopt.add_argument("--revision", required=True, help="the latest revision id")
360
+ _add_target(adopt)
361
+ adopt.add_argument("--choice", required=True, choices=followup.CHOICES)
362
+
363
+ amended = sub.add_parser("complete-amendment",
364
+ help="move a follow-up routed to plan amendment to the revision after its amended plan is approved")
365
+ _add_common(amended)
366
+ amended.add_argument("--revision", required=True, help="the revision the routed adoption named")
367
+ _add_target(amended)
368
+
369
+ for name, help_text in (
370
+ ("question-prompt", "write the dispatch input of one direct question"),
371
+ ("collect-answer", "record the addressee's answer to one direct question"),
372
+ ):
373
+ command = sub.add_parser(name, help=help_text)
374
+ _add_common(command)
375
+ command.add_argument("--question", required=True, help="the question id (Q-NNNN)")
376
+ _add_run(command)
377
+ return parser
378
+
379
+
380
+ def _workflow(store: DiscussionStore, args: argparse.Namespace) -> dict:
381
+ run = workflow.load_run(store, args.run_manifest)
382
+ if args.command == "cycle-open":
383
+ return workflow.open_cycle(store, run)
384
+ if args.command == "settle":
385
+ return workflow.settle_cycle(store, run)
386
+ if args.command == "cycle-close":
387
+ return workflow.close_cycle(store, run)
388
+ if args.command == "recover":
389
+ return workflow.choose_recovery(store, run, args.failure, args.choice)
390
+ if args.command == "question-prompt":
391
+ return workflow.question_dispatch(store, run, args.question)
392
+ return workflow.collect_answer(store, run, args.question)
393
+
394
+
395
+ _WORKFLOW_COMMANDS = frozenset({
396
+ "cycle-open", "settle", "cycle-close", "recover", "question-prompt", "collect-answer",
397
+ })
398
+
399
+
400
+ def _write(store: DiscussionStore, args: argparse.Namespace) -> tuple[dict, bool]:
401
+ source_run = resolve_source_run(store, args.run_manifest)
402
+ if args.command == "confirm-subject":
403
+ return append_record(store, "subject", {"subject": args.subject, "question": args.question},
404
+ author=USER, source_run=source_run)
405
+ if args.command == "ask":
406
+ return append_record(store, "question", {"to": USER, "text": args.text},
407
+ author=args.author, source_run=source_run)
408
+ if args.command == "address":
409
+ return append_record(store, "question", {"to": args.to, "text": args.text},
410
+ author=USER, source_run=source_run)
411
+ if args.command == "answer":
412
+ return append_record(store, "answer", {"questionId": args.question, "text": args.text},
413
+ author=args.author, source_run=source_run)
414
+ if args.command == "add-material":
415
+ return append_record(store, "material", {"materialKind": args.kind, "text": args.text},
416
+ author=args.author, source_run=source_run)
417
+ if args.command == "add-requirement":
418
+ return append_record(store, "requirement", {"appliesTo": args.applies_to, "text": args.text},
419
+ author=args.author, source_run=source_run)
420
+ if args.command == "add-scope":
421
+ return append_record(store, "scope", {"scopeKind": args.kind, "text": args.text},
422
+ author=args.author, source_run=source_run)
423
+ if args.command == "withdraw":
424
+ return append_record(store, "withdrawal", {"itemId": args.item, "reason": args.reason},
425
+ author=USER, source_run=source_run)
426
+ if args.command == "confirm-completion":
427
+ return confirm_completion(store, draft_version=args.draft_version, direction=args.direction,
428
+ risk_acknowledgement=args.risk, source_run=source_run)
429
+ if args.command == "reopen":
430
+ return append_record(store, "reopen", {"revisionId": args.revision, "reason": args.reason},
431
+ author=USER, source_run=source_run)
432
+ return append_record(store, "decision", {"questionId": args.question, "text": args.text},
433
+ author=USER, source_run=source_run)
434
+
435
+
436
+ def main(argv: list[str] | None = None) -> int:
437
+ args = build_parser().parse_args(argv)
438
+
439
+ def emit(payload: dict) -> None:
440
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
441
+
442
+ try:
443
+ project_root = Path(resolve_project_root(explicit_root=args.project_root, cwd=args.cwd))
444
+ identity = resolve_task_identity(project_root, args.task_key)
445
+ except (ResolverError, StateError) as exc:
446
+ emit({"ok": False, "stage": "resolve", "reason": str(exc)})
447
+ return 2
448
+ store = DiscussionStore(project_root, identity["taskKey"])
449
+ try:
450
+ if args.command == "draft":
451
+ print(render_draft_text(read_draft(store)), end="")
452
+ return 0
453
+ if args.command == "status":
454
+ emit({"ok": True, **workflow.status(read_draft(store)), "memoryRegistration": memory.views(store)})
455
+ return 0
456
+ if args.command == "memory-sync":
457
+ summary = _read_summary(args.summary_file) if args.summary_file else None
458
+ emit({"ok": True, "memory": memory.sync(store, okstra_cli=args.okstra_cli,
459
+ revision_id=args.revision, summary_text=summary)})
460
+ return 0
461
+ if args.command == "record":
462
+ print(render_full_record(store), end="")
463
+ return 0
464
+ if args.command == "revisions":
465
+ emit({"ok": True, "revisions": read_draft(store)["revisions"]})
466
+ return 0
467
+ if args.command == "revision-show":
468
+ _, data = read_revision(store, args.revision)
469
+ sys.stdout.write(data.decode("utf-8"))
470
+ return 0
471
+ if args.command in _FOLLOW_UP_COMMANDS:
472
+ emit({"ok": True, **_follow_up(store, args)})
473
+ return 0
474
+ if args.command in _WORKFLOW_COMMANDS:
475
+ emit({"ok": True, **_workflow(store, args)})
476
+ return 0
477
+ record, created = _write(store, args)
478
+ draft = read_draft(store)
479
+ except DiscussionStoreError as exc:
480
+ emit({"ok": False, "stage": args.command, "reason": str(exc)})
481
+ return 1
482
+ payload = {
483
+ "ok": True,
484
+ "created": created,
485
+ "record": record,
486
+ "pendingForUser": draft["pendingForUser"],
487
+ "pendingForParticipants": draft["pendingForParticipants"],
488
+ }
489
+ if args.command == "confirm-completion":
490
+ payload["memory"] = _register(store, record["revisionId"], args.okstra_cli)
491
+ emit(payload)
492
+ return 0
493
+
494
+
495
+ def _read_summary(path: str) -> str:
496
+ try:
497
+ return Path(path).read_text(encoding="utf-8")
498
+ except OSError as exc:
499
+ raise DiscussionStoreError(f"--summary-file {path} cannot be read: {exc}") from exc
500
+
501
+
502
+ def _register(store: DiscussionStore, revision_id: str, okstra_cli: str) -> dict:
503
+ """확정본은 이미 발행됐다 — 등록 실패는 발행을 되돌리지 않고 `pending` 으로 남아 `memory-sync` 가 다시 부른다."""
504
+ try:
505
+ return memory.register_revision(store, revision_id, okstra_cli=okstra_cli)
506
+ except (DiscussionStoreError, OSError) as exc:
507
+ return {"revisionId": revision_id, "sourceKey": memory.source_key(revision_id), "status": memory.PENDING,
508
+ "reason": f"registration did not start: {exc}", "next": "okstra discussion memory-sync <task-key>"}
509
+
510
+
511
+ if __name__ == "__main__":
512
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """토론 시작 입력을 분류·검증하고, 그 입력으로 단계 소유 입력 문서를 만든다."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from okstra_ctl.prepare_error import PrepareError
8
+
9
+ from .store import (
10
+ USER,
11
+ DiscussionStore,
12
+ DiscussionStoreError,
13
+ SourceRun,
14
+ append_record,
15
+ completed_revision,
16
+ has_records,
17
+ read_draft,
18
+ render_draft_text,
19
+ same_task,
20
+ )
21
+
22
+ _URL = re.compile(r"^https?://\S+$")
23
+ _TASK_KEY = re.compile(r"^[^:\s]+:[^:\s]+:[^:\s]+$")
24
+
25
+
26
+ def _task_key(inp) -> str:
27
+ return f"{inp.project_id}:{inp.task_group}:{inp.task_id}"
28
+
29
+
30
+ def classify_start_input(text: str, project_root: Path, task_key: str) -> tuple[str, str]:
31
+ """호출자에게 종류를 묻지 않고 값의 모양으로 정한다 — URL·프로젝트 안 파일은 link,
32
+ task key 모양은 이 task 의 토론, 나머지는 description."""
33
+ value = text.strip()
34
+ if not value:
35
+ raise PrepareError(
36
+ "--discussion-start is empty — give a ticket or project link, a short "
37
+ "description, or this task's key to continue its discussion"
38
+ )
39
+ if _URL.match(value):
40
+ return "link", value
41
+ if _TASK_KEY.match(value):
42
+ if not same_task(value, task_key):
43
+ raise PrepareError(
44
+ f"--discussion-start names the discussion of {value}, not of {task_key}; "
45
+ "a discussion continues in its own task — start this task from a link "
46
+ "or a description instead"
47
+ )
48
+ if not has_records(DiscussionStore(project_root, task_key)):
49
+ raise PrepareError(
50
+ f"{task_key} has no discussion record to continue yet — start it from "
51
+ "a link or a description"
52
+ )
53
+ return "discussion", task_key
54
+ candidate = project_root / value
55
+ if "\n" not in value and candidate.is_file():
56
+ try:
57
+ return "link", candidate.resolve().relative_to(project_root.resolve()).as_posix()
58
+ except ValueError:
59
+ # expected-miss: 프로젝트 밖 파일은 링크가 아니라 설명으로 기록한다.
60
+ return "description", value
61
+ return "description", value
62
+
63
+
64
+ def validate_discussion_start(project_root: Path, inp) -> None:
65
+ """prepare 의 어떤 쓰기보다 먼저 돈다 — 입력 오류가 부분 run 을 남기지 않게."""
66
+ if inp.task_type != "discussion":
67
+ if inp.discussion_start:
68
+ raise PrepareError(
69
+ "--discussion-start is only meaningful with --task-type discussion; "
70
+ f"got {inp.task_type}"
71
+ )
72
+ return
73
+ if str(inp.brief_path).strip() not in ("", "."):
74
+ raise PrepareError(
75
+ "--task-brief is not accepted for --task-type discussion — pass "
76
+ "--discussion-start <link | short description | this task's key>; "
77
+ "prepare writes the discussion input document itself"
78
+ )
79
+ task_key = _task_key(inp)
80
+ kind, _ = classify_start_input(inp.discussion_start, project_root, task_key)
81
+ try:
82
+ completed = completed_revision(DiscussionStore(project_root, task_key))
83
+ except DiscussionStoreError as exc:
84
+ raise PrepareError(str(exc)) from exc
85
+ if completed is not None and kind != "discussion":
86
+ raise PrepareError(
87
+ f"{task_key} is completed as {completed}; continue it with --discussion-start "
88
+ f"{task_key} and reopen it (`okstra discussion reopen`) to discuss a change"
89
+ )
90
+
91
+
92
+ def materialize_discussion_input(project_root: Path, inp, ctx: dict) -> None:
93
+ task_key = _task_key(inp)
94
+ kind, text = classify_start_input(inp.discussion_start, project_root, task_key)
95
+ store = DiscussionStore(project_root, task_key)
96
+ try:
97
+ if kind != "discussion":
98
+ append_record(
99
+ store, "start", {"startKind": kind, "text": text},
100
+ author=USER,
101
+ source_run=SourceRun(
102
+ manifest=str(ctx["RUN_MANIFEST_RELATIVE_PATH"]),
103
+ participants=frozenset(),
104
+ ),
105
+ )
106
+ draft = read_draft(store)
107
+ except DiscussionStoreError as exc:
108
+ raise PrepareError(str(exc)) from exc
109
+ store.input_path.write_text(
110
+ "# Discussion input\n\n"
111
+ f"- Start input of this run: [{kind}] {text}\n"
112
+ f"- Run: `{ctx['RUN_MANIFEST_RELATIVE_PATH']}`\n\n"
113
+ + render_draft_text(draft),
114
+ encoding="utf-8",
115
+ )
116
+ inp.brief_path = store.input_path