plan_issue/commands/record.rs
1use std::path::PathBuf;
2
3use clap::{Args, Subcommand, ValueEnum};
4use serde::Serialize;
5
6#[derive(Debug, Clone, Args, Serialize)]
7pub struct RecordArgs {
8 #[command(subcommand)]
9 pub command: RecordCommand,
10}
11
12#[derive(Debug, Clone, Subcommand, Serialize)]
13pub enum RecordCommand {
14 /// Open a provider issue from a plan bundle and post initial lifecycle
15 /// comments (v3 issue-backed plan record contract). Idempotent: re-running
16 /// for the same bundle resumes the existing tracker instead of creating a
17 /// duplicate. The dedup key is the source snapshot identity (repo-relative
18 /// path + last-commit SHA) embedded in the source lifecycle comment; on a
19 /// match only the missing lifecycle comments are attached. A partial open is
20 /// therefore safe to retry.
21 Open(Box<RecordOpenArgs>),
22
23 /// Attach source, plan, and initial state lifecycle comments to an
24 /// existing provider issue.
25 Attach(Box<RecordAttachArgs>),
26
27 /// Append a canonical lifecycle comment (state, session, validation,
28 /// review, or closeout) to an existing plan record issue.
29 Post(Box<RecordPostArgs>),
30
31 /// Recompute and edit the dashboard issue body from active payload
32 /// evidence. Old state payload formats require one-off migration/repair.
33 #[command(
34 after_help = "State payload replacement policy: this command targets the active payload contract only. Old state payload formats require one-off migration/repair outside the main CLI; no long-term v2 reader or mixed old/new stream reconciliation is provided."
35 )]
36 RepairDashboard(Box<RecordRepairDashboardArgs>),
37
38 /// Close a plan record issue after the strict lifecycle gate passes.
39 Close(Box<RecordCloseArgs>),
40
41 /// Audit issue body and comments for the active payload contract. Old
42 /// state payload formats require one-off migration/repair.
43 #[command(
44 after_help = "State payload replacement policy: this command targets the active payload contract only. Old state payload formats require one-off migration/repair outside the main CLI; no long-term v2 reader or mixed old/new stream reconciliation is provided."
45 )]
46 Audit(Box<RecordAuditArgs>),
47
48 /// Preview the visible Markdown or JSON payload skeleton for a lifecycle
49 /// role. Non-mutating; backed by the vNext lifecycle role registry.
50 Template(Box<RecordTemplateArgs>),
51
52 /// Re-materialize a plan bundle's `source` and `plan` documents from a
53 /// tracking issue's frozen snapshot comments, writing each file to its
54 /// canonical path under `--out`. The inverse of `record open`'s
55 /// snapshot rendering. The `state` role is a rendered lifecycle view
56 /// (not a verbatim file snapshot) and is not restored.
57 #[command(
58 after_help = "Only the `source` and `plan` roles embed a verbatim file snapshot in a <details> block. The `state` role is rendered from structured payload data, so it is not a restorable file snapshot; its latest rendered form stays visible on the issue."
59 )]
60 Restore(Box<RecordRestoreArgs>),
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
64pub enum RecordProfile {
65 Tracking,
66 Dispatch,
67}
68
69impl RecordProfile {
70 pub fn as_str(self) -> &'static str {
71 match self {
72 Self::Tracking => "tracking",
73 Self::Dispatch => "dispatch",
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
79pub enum LifecycleCommentKind {
80 #[value(name = "source", alias = "source-snapshot")]
81 Source,
82 #[value(name = "plan", alias = "plan-snapshot")]
83 Plan,
84 State,
85 Session,
86 Validation,
87 Review,
88 Closeout,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
92pub enum TaskLedgerDisplay {
93 /// Expand the rows when the state is terminal, collapse them otherwise.
94 Auto,
95 /// Wrap the rows in a closed `<details>` fold.
96 Collapsed,
97 /// Render the rows directly with no fold (required for the final
98 /// pre-closeout state; visible-lint forbids a `<details>` wrapper there).
99 Expanded,
100 /// Wrap the rows in an open `<details open>` fold — the toggle stays, but
101 /// the ledger is visible by default. Used for the first Execution State so
102 /// the full plan is visible on load while remaining collapsible.
103 Open,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
107pub enum TemplateFormatArg {
108 Markdown,
109 Json,
110}
111
112impl TemplateFormatArg {
113 pub fn as_str(self) -> &'static str {
114 match self {
115 Self::Markdown => "markdown",
116 Self::Json => "json",
117 }
118 }
119}
120
121#[derive(Debug, Clone, Args, Serialize)]
122pub struct RecordTemplateArgs {
123 /// Lifecycle profile for the template preview.
124 #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
125 pub profile: RecordProfile,
126
127 /// Lifecycle role to preview.
128 #[arg(long, value_enum)]
129 pub kind: LifecycleCommentKind,
130
131 /// Template output shape. `markdown` prints the visible body skeleton;
132 /// `json` prints the payload data skeleton. (Named `--shape` rather than
133 /// `--format` because the global `--format text|json` controls the
134 /// command envelope and would shadow a subcommand `--format` flag.)
135 #[arg(long, value_enum, default_value_t = TemplateFormatArg::Markdown)]
136 pub shape: TemplateFormatArg,
137}
138
139impl LifecycleCommentKind {
140 pub fn as_str(self) -> &'static str {
141 match self {
142 Self::Source => "source",
143 Self::Plan => "plan",
144 Self::State => "state",
145 Self::Session => "session",
146 Self::Validation => "validation",
147 Self::Review => "review",
148 Self::Closeout => "closeout",
149 }
150 }
151}
152
153#[derive(Debug, Clone, Args, Serialize)]
154pub struct RecordAuditArgs {
155 /// Provider issue body Markdown.
156 #[arg(long = "body-file", value_name = "path")]
157 pub body_file: Option<PathBuf>,
158
159 /// JSON containing either `comments` from `gh issue view --json comments`
160 /// or a raw array of comment objects.
161 #[arg(long = "comments-json", value_name = "path")]
162 pub comments_json: PathBuf,
163
164 /// Expected profile. When omitted, all recognized markers are reported.
165 #[arg(long, value_enum)]
166 pub profile: Option<RecordProfile>,
167
168 /// Also run the visible-completeness lint against the latest comment body
169 /// per role. Produces stable role-specific failure codes
170 /// (`state-missing-task-ledger`, `validation-missing-overall`, …) in the
171 /// `visible` block of the audit result.
172 #[arg(long = "expect-visible", default_value_t = false)]
173 pub expect_visible: bool,
174}
175
176#[derive(Debug, Clone, Args, Serialize)]
177pub struct RecordRestoreArgs {
178 /// Provider issue number or full URL to restore from (online mode).
179 /// Requires the global `--repo owner/repo`. Omit when reading offline
180 /// snapshots via `--comments-json`.
181 #[arg(long, value_name = "issue")]
182 pub issue: Option<String>,
183
184 /// Output directory. Restored files are written at their canonical
185 /// repo-relative paths under this directory.
186 #[arg(long, value_name = "dir")]
187 pub out: PathBuf,
188
189 /// Offline issue body Markdown (optional; the dashboard body is not
190 /// required for restore since snapshots live in comments).
191 #[arg(long = "body-file", value_name = "path")]
192 pub body_file: Option<PathBuf>,
193
194 /// Offline comments JSON from `gh issue view --json comments` (or a raw
195 /// array of comment objects). When provided, restore runs without any
196 /// provider call.
197 #[arg(long = "comments-json", value_name = "path")]
198 pub comments_json: Option<PathBuf>,
199
200 /// Restrict restoration to a lifecycle profile. When omitted, source and
201 /// plan snapshots of any profile are accepted. Overwrite of existing
202 /// files is governed by the global `--force` flag.
203 #[arg(long, value_enum)]
204 pub profile: Option<RecordProfile>,
205}
206
207#[derive(Debug, Clone, Args, Serialize)]
208pub struct RecordOpenArgs {
209 /// Lifecycle profile for the record.
210 #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
211 pub profile: RecordProfile,
212
213 /// Plan bundle directory. The bundle directory contains the source,
214 /// plan, and execution-state Markdown files using the
215 /// `<slug>-discussion-source.md` / `<slug>-review-source.md`,
216 /// `<slug>-plan.md`, and `<slug>-execution-state.md` naming
217 /// convention validated by `plan-tooling validate`.
218 #[arg(long, value_name = "dir")]
219 pub bundle: Option<PathBuf>,
220
221 /// Explicit source document path. Overrides bundle derivation.
222 #[arg(long = "source-file", value_name = "path")]
223 pub source_file: Option<PathBuf>,
224
225 /// Explicit plan document path. Overrides bundle derivation.
226 #[arg(long = "plan-file", value_name = "path")]
227 pub plan_file: Option<PathBuf>,
228
229 /// Explicit execution-state document path. Overrides bundle derivation.
230 #[arg(long = "execution-state-file", value_name = "path")]
231 pub execution_state_file: Option<PathBuf>,
232
233 /// Issue title. Defaults to the plan title.
234 #[arg(long, value_name = "text")]
235 pub title: Option<String>,
236
237 /// Allow opening the record even when local plan files are dirty.
238 #[arg(long = "allow-dirty")]
239 pub allow_dirty: bool,
240
241 /// Label to apply at issue creation. Repeatable. Empty values are
242 /// dropped. Names are passed through to `gh issue create --label`.
243 #[arg(long = "label", value_name = "NAME")]
244 pub labels: Vec<String>,
245
246 /// Deterministic fixture mode. The directory is consumed instead of
247 /// live provider calls.
248 #[arg(long, value_name = "dir")]
249 pub fixture: Option<PathBuf>,
250}
251
252#[derive(Debug, Clone, Args, Serialize)]
253pub struct RecordAttachArgs {
254 /// Provider issue number or full URL.
255 #[arg(long, value_name = "issue")]
256 pub issue: String,
257
258 /// Lifecycle profile for the record.
259 #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
260 pub profile: RecordProfile,
261
262 /// Plan bundle directory. The bundle directory contains the source,
263 /// plan, and execution-state Markdown files using the same naming
264 /// convention as `record open`.
265 #[arg(long, value_name = "dir")]
266 pub bundle: Option<PathBuf>,
267
268 /// Explicit source document path. Overrides bundle derivation.
269 #[arg(long = "source-file", value_name = "path")]
270 pub source_file: Option<PathBuf>,
271
272 /// Explicit plan document path. Overrides bundle derivation.
273 #[arg(long = "plan-file", value_name = "path")]
274 pub plan_file: Option<PathBuf>,
275
276 /// Explicit execution-state document path. Overrides bundle derivation.
277 #[arg(long = "execution-state-file", value_name = "path")]
278 pub execution_state_file: Option<PathBuf>,
279
280 /// Issue title for dashboard rendering. Defaults to the plan title.
281 #[arg(long, value_name = "text")]
282 pub title: Option<String>,
283
284 /// Allow attaching the record even when local plan files are dirty.
285 #[arg(long = "allow-dirty")]
286 pub allow_dirty: bool,
287}
288
289#[derive(Debug, Clone, Args, Serialize)]
290pub struct RecordPostArgs {
291 /// Provider issue number or full URL.
292 #[arg(long, value_name = "issue")]
293 pub issue: String,
294
295 /// Lifecycle profile for the marker and payload.
296 #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
297 pub profile: RecordProfile,
298
299 /// Lifecycle comment kind. `source` and `plan` kinds are owned by
300 /// `record open` and rejected here.
301 #[arg(long, value_enum)]
302 pub kind: LifecycleCommentKind,
303
304 /// JSON file containing the structured payload `data` field.
305 #[arg(long = "payload-file", value_name = "path")]
306 pub payload_file: Option<PathBuf>,
307
308 /// Markdown execution-state document for state lifecycle comments.
309 #[arg(
310 long = "execution-state-file",
311 value_name = "path",
312 conflicts_with = "summary_file"
313 )]
314 pub execution_state_file: Option<PathBuf>,
315
316 /// Visible Markdown commentary appended after the structured payload.
317 #[arg(long = "summary-file", value_name = "path")]
318 pub summary_file: Option<PathBuf>,
319
320 /// Task Ledger display mode for state lifecycle comments
321 /// (`auto`, `collapsed`, `expanded`, `open`).
322 #[arg(
323 long = "task-ledger-display",
324 value_enum,
325 default_value_t = TaskLedgerDisplay::Auto
326 )]
327 pub task_ledger_display: TaskLedgerDisplay,
328
329 /// Add a label alongside the lifecycle comment in live mode. Repeatable.
330 #[arg(long = "add-label", value_name = "NAME")]
331 pub add_labels: Vec<String>,
332
333 /// Remove a label alongside the lifecycle comment in live mode.
334 /// Repeatable.
335 #[arg(long = "remove-label", value_name = "NAME")]
336 pub remove_labels: Vec<String>,
337
338 /// Deterministic fixture mode.
339 #[arg(long, value_name = "dir")]
340 pub fixture: Option<PathBuf>,
341}
342
343#[derive(Debug, Clone, Args, Serialize)]
344pub struct RecordRepairDashboardArgs {
345 /// Provider issue number or full URL.
346 #[arg(long, value_name = "issue")]
347 pub issue: Option<String>,
348
349 /// Provider issue body Markdown (deterministic mode).
350 #[arg(long = "body-file", value_name = "path")]
351 pub body_file: Option<PathBuf>,
352
353 /// JSON containing either `comments` from `gh issue view --json
354 /// comments` or a raw array of comment objects (deterministic mode).
355 #[arg(long = "comments-json", value_name = "path")]
356 pub comments_json: Option<PathBuf>,
357
358 /// Deterministic fixture mode.
359 #[arg(long, value_name = "dir")]
360 pub fixture: Option<PathBuf>,
361
362 /// Write rendered Markdown to this path instead of editing the issue.
363 #[arg(long, value_name = "path")]
364 pub out: Option<PathBuf>,
365}
366
367#[derive(Debug, Clone, Args, Serialize)]
368pub struct RecordCloseArgs {
369 /// Provider issue number or full URL.
370 #[arg(long, value_name = "issue")]
371 pub issue: String,
372
373 /// Lifecycle profile of the record being closed.
374 #[arg(long, value_enum, default_value_t = RecordProfile::Tracking)]
375 pub profile: RecordProfile,
376
377 /// Linked PR reference. Repeatable. Each ref is cross-checked against
378 /// the latest state payload and verified through the provider for
379 /// merge status.
380 #[arg(long = "linked-pr", value_name = "ref")]
381 pub linked_pr: Vec<String>,
382
383 /// Approval evidence. May be a provider comment URL or non-empty
384 /// approval text.
385 #[arg(long = "approval", value_name = "text")]
386 pub approval: Option<String>,
387
388 /// Plan bundle directory. Used for local source/plan commit
389 /// verification when provided.
390 #[arg(long, value_name = "dir")]
391 pub bundle: Option<PathBuf>,
392
393 /// Deterministic test mode: issue body Markdown.
394 #[arg(long = "body-file", value_name = "path")]
395 pub body_file: Option<PathBuf>,
396
397 /// Deterministic test mode: comments JSON.
398 #[arg(long = "comments-json", value_name = "path")]
399 pub comments_json: Option<PathBuf>,
400
401 /// Add a label as part of the closeout transition in live mode (e.g.
402 /// `state::closed`). Repeatable.
403 #[arg(long = "add-label", value_name = "NAME")]
404 pub add_labels: Vec<String>,
405
406 /// Remove a label as part of the closeout transition in live mode
407 /// (e.g. earlier `state::*` markers). Repeatable.
408 #[arg(long = "remove-label", value_name = "NAME")]
409 pub remove_labels: Vec<String>,
410
411 /// Deterministic fixture mode. Contains issue body, comments JSON, and
412 /// PR snapshots used in place of provider lookups.
413 #[arg(long, value_name = "dir")]
414 pub fixture: Option<PathBuf>,
415
416 /// Allow the linked-PR branch of the strict closeout gate to pass
417 /// even when the provider only reports a single aggregate check
418 /// state (no required/non-required breakdown) and that aggregate
419 /// state is `failure`. Use this when you have manually verified
420 /// that the failing checks are non-required. Requires
421 /// `--allow-non-required-check-failure-reason`. The override and
422 /// the observed non-required failures are recorded in the
423 /// closeout-comment evidence block.
424 #[arg(long = "allow-non-required-check-failure", default_value_t = false)]
425 pub allow_non_required_check_failure: bool,
426
427 /// Required when `--allow-non-required-check-failure` is set.
428 /// Non-empty free-form text describing why the operator verified
429 /// the failing checks are safe to ignore. Stored verbatim in the
430 /// closeout-comment evidence block.
431 #[arg(long = "allow-non-required-check-failure-reason", value_name = "text")]
432 pub allow_non_required_check_failure_reason: Option<String>,
433}