Skip to main content

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    /// Composes with `--summary-file`: the summary renders above the
310    /// execution-state document.
311    #[arg(long = "execution-state-file", value_name = "path")]
312    pub execution_state_file: Option<PathBuf>,
313
314    /// Visible Markdown commentary rendered after the comment header, above
315    /// the generated lifecycle body (and above the execution-state document
316    /// when `--execution-state-file` is also given).
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}