@chief-clancy/plan 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chief-clancy/plan",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Implementation planner for Claude Code — decompose briefs into actionable plans",
6
6
  "author": "Alex Clapperton",
@@ -17,7 +17,9 @@ Examples:
17
17
  Optional flags:
18
18
 
19
19
  - **Skip confirmation:** `--afk` — auto-confirm without prompting (for automation)
20
+ - **Push approved plan to a board ticket:** `--push` — when approving a local plan stem in standalone+board mode, skip the interactive `[y/N]` prompt and push the approved plan to the source ticket as a comment immediately. Combined with `--afk`, this is the unattended-automation path. Without `--push`, an interactive approval still gets a `[y/N]` prompt (default No — never surprise-write to a board); an `--afk` approval without `--push` stays local-only and logs `LOCAL_ONLY` to `.clancy/progress.txt`. `--push` is also the **retry path** for a previously failed push: re-running `/clancy:approve-plan {stem} --push --ticket {KEY}` after a board push failure falls through Step 4a's `EEXIST` check (the marker stays in place) and re-attempts the Step 4c push.
21
+ - **Override the auto-detected ticket key:** `--ticket {KEY}` — bypass the `**Source:**` auto-detect from the plan file and push to the explicit `{KEY}` instead. The override `{KEY}` is validated against the configured board's regex before any push attempt — a malformed key is a hard error. Useful when the brief Source field is missing, ambiguous, or points at the wrong ticket. `--ticket` is **ignored** under `--afk` without `--push` (no push happens, so there is nothing to override).
20
22
 
21
23
  @.claude/clancy/workflows/approve-plan.md
22
24
 
23
- Follow the approve-plan workflow above. Detect the install context, resolve the argument (plan-file stem or ticket key), and either write the local marker (Step 4a/4b) or run the existing board transport flow (Steps 5/5b/6). Do not implement anything — approval only.
25
+ Follow the approve-plan workflow above. Detect the install context, resolve the argument (plan-file stem or ticket key), and either write the local marker (Step 4a/4b) followed by an optional board push (Step 4c) or run the existing board transport flow (Steps 5/5b/6). Do not implement anything — approval only.
@@ -5,7 +5,7 @@
5
5
  Approve a Clancy implementation plan. Behaviour depends on the install context:
6
6
 
7
7
  - **Standalone mode** (no `.clancy/.env`): write a local `.clancy/plans/{stem}.approved` marker file with the plan's SHA-256 and approval timestamp. The marker is the gate any plan-implementing tool checks before applying changes (a dedicated `/clancy:implement-from` slash command is deferred until `@chief-clancy/dev` is extracted; users in the meantime ask Claude Code to apply the plan via natural-language instruction or install the full pipeline)
8
- - **Standalone+board mode** (`.clancy/.env` present, no full pipeline): with a board ticket key, run the existing comment-to-description transport flow; with a plan-file stem, write the local marker (board push lands in PR 9)
8
+ - **Standalone+board mode** (`.clancy/.env` present, no full pipeline): with a board ticket key, run the existing comment-to-description transport flow; with a plan-file stem, write the local marker and optionally push it to the source board ticket as a comment
9
9
  - **Terminal mode** (full pipeline installed): existing behaviour — promote an approved plan from a ticket comment to the ticket description and transition the ticket to the implementation queue
10
10
 
11
11
  ---
@@ -95,7 +95,7 @@ If declined: `Cancelled.` Stop.
95
95
 
96
96
  In these modes the argument may be either a plan-file stem or a board ticket key. **Try plan-file lookup first (does `.clancy/plans/{arg}.md` exist?)**, then fall back to ticket-key validation. The plan stem wins over ticket key on collision (e.g. if `PROJ-123.md` exists in `.clancy/plans/` AND `PROJ-123` is a valid ticket key, the plan stem wins). Document the collision rule explicitly so users are not surprised.
97
97
 
98
- **With argument that resolves to a plan file:** continue to Step 4 (Confirm), then Step 4a (Write local marker). The board push offer for plan-file-stem mode is deferred to a future PR — for now the local marker is the only side effect.
98
+ **With argument that resolves to a plan file:** continue to Step 4 (Confirm), then Step 4a (Write local marker), Step 4b (Update brief marker), and Step 4c (Optional board push). Step 4c is best-effort and gated on board credentials being present in `.clancy/.env`; in standalone+board mode it offers to push the approved plan to the source ticket as a comment (interactive `[y/N]` prompt, default No never surprise-write to a board), and in standalone-only mode it skips silently because there are no credentials. See Step 4c below for the full decision matrix and the `--push` / `--ticket KEY` flags.
99
99
 
100
100
  **With argument that does not resolve to a plan file:** validate as a ticket key per the board configured in `.clancy/.env` (case-insensitive):
101
101
 
@@ -114,7 +114,7 @@ Invalid ticket key: {input}. Expected format: {board-specific example}.
114
114
 
115
115
  Stop.
116
116
 
117
- If valid: proceed with that key. The board transport flow runs (Steps 3-7 below) — this is the existing behaviour, unchanged from before PR 7b.
117
+ If valid: proceed with that key. The board transport flow runs (Steps 3-7 below).
118
118
 
119
119
  **No argument:**
120
120
 
@@ -385,7 +385,19 @@ Two `key=value` lines, each terminated with `\n`. No JSON, no extra whitespace,
385
385
 
386
386
  ### Handle EEXIST (already-approved)
387
387
 
388
- If the exclusive create fails with `EEXIST`, the marker already exists — the plan was previously approved. Stop with:
388
+ If the exclusive create fails with `EEXIST`, the marker already exists — the plan was previously approved. **The next step depends on whether `--push` was passed.** Check the flag first; the stop branch only applies when `--push` is absent.
389
+
390
+ **If `--push` IS set (retry path):** the user is re-running approve-plan to retry a previously failed board push. Step 4a **does not stop**. Instead:
391
+
392
+ 1. The marker is **not re-written** — the existing `.clancy/plans/{stem}.approved` file stays in place, byte-for-byte unchanged. The original `sha256=` and `approved_at=` values from the first approval are preserved.
393
+ 2. **Skip Step 4b entirely.** The brief marker was already updated on the original approval; there is no work for 4b to do on a retry.
394
+ 3. **Fall through directly to Step 4c.** The retry path enters Step 4c with the same gate evaluation as a fresh approval (board credentials must be present, Source must be readable from the plan file or `--ticket` override must be supplied).
395
+
396
+ This is the **only** mechanism to re-attempt a failed push. There is no `--push-only` flag and no marker-deletion workflow. The contract is: a Step 4c push failure leaves the marker in place (see Step 4c push-failure section), and a `--push` re-run honours that marker by skipping Step 4a's write and Step 4b's brief update, going straight to 4c. The on-disk approval timestamp inside `.clancy/plans/{stem}.approved` is preserved across retries (Step 4a does not re-write it).
397
+
398
+ Local-mode audit logging in Step 7, however, runs **per invocation** — every retry appends a fresh `LOCAL_APPROVE_PLAN` row to `.clancy/progress.txt` followed by that attempt's Step 4c outcome row (e.g. `BOARD_PUSH_FAILED` then `LOCAL_APPROVE_PLAN_PUSH` after a successful retry). The audit trail therefore reflects every retry attempt, while the `.approved` marker on disk reflects only the original approval. A downstream audit grep that wants "the approval moment" reads the marker file's `approved_at`; a grep that wants "all approve-plan invocations for this stem" reads `progress.txt`. The two surfaces answer different questions and that asymmetry is intentional.
399
+
400
+ **If `--push` is NOT set:** stop with:
389
401
 
390
402
  ```
391
403
  Plan already approved: {stem}
@@ -408,7 +420,7 @@ A dedicated `/clancy:implement-from` slash command is **deferred** until `@chief
408
420
 
409
421
  ### After writing the marker
410
422
 
411
- After the marker is written successfully, update the source brief file's marker comment (Step 4b below), then jump to Step 7 (Confirm and log). Steps 5, 5b, and 6 (board transport) are skipped entirely in plan-file stem mode.
423
+ After the marker is written successfully, update the source brief file's marker comment (Step 4b below), then continue to Step 4c (Optional board push). Step 4c is best-effort and gated on board credentials being present — when the gate fails it skips silently and the flow continues to Step 7. Steps 5, 5b, and 6 (board ticket-key transport) are skipped entirely in plan-file stem mode regardless of whether Step 4c runs.
412
424
 
413
425
  ---
414
426
 
@@ -438,8 +450,8 @@ Open `.clancy/briefs/{brief-filename}` and find the marker comment matching this
438
450
 
439
451
  This matches all of:
440
452
 
441
- - `<!-- planned:1,2,3 -->` (no approved prefix yet current state from PR 6b)
442
- - `<!-- approved:1 planned:1,2,3 -->` (PR 7b adds the approved prefix)
453
+ - `<!-- planned:1,2,3 -->` (no approved prefix — earlier marker shape)
454
+ - `<!-- approved:1 planned:1,2,3 -->` (with the approved prefix)
443
455
  - `<!-- approved: planned:1,2,3 -->` (empty approved list — should not happen but handle gracefully)
444
456
  - `<!--planned:1,2,3-->` (no surrounding spaces — hand-edited)
445
457
 
@@ -468,7 +480,315 @@ The plan is still approved. The .clancy/plans/{stem}.approved marker is in place
468
480
  You can manually update .clancy/briefs/{brief}.md if needed.
469
481
  ```
470
482
 
471
- After Step 4b completes (successfully or with a warning), jump to Step 7 (Confirm and log). Skip Steps 5, 5b, and 6 entirely.
483
+ After Step 4b completes (successfully or with a warning), continue to Step 4c (Optional board push). Step 4c is best-effort and gated on board credentials being present — when the gate fails it skips silently and the flow continues to Step 7. Steps 5, 5b, and 6 (the board ticket-key transport flow) remain unreachable in plan-file stem mode regardless of whether Step 4c runs.
484
+
485
+ ---
486
+
487
+ ## Step 4c — Optional board push (best-effort)
488
+
489
+ In standalone+board mode (board credentials present alongside a local plan-file stem approval), offer to push the approved plan to the source board ticket as a comment. This closes the "I have credentials and I want both modes" UX cliff: the user gets the local marker AND the board comment in one approval.
490
+
491
+ ### Run conditions (both must be true)
492
+
493
+ Step 4c runs only when **both** of these gates pass:
494
+
495
+ 1. **A local approval marker exists for this stem** — either Step 4a just wrote it (fresh approval), or Step 4a hit `EEXIST` with `--push` set and fell through to Step 4c with the marker already in place (retry path — see Step 4a's `--push` retry branch above). If Step 4a stopped on `EEXIST` without `--push`, or if the resolved argument was a board ticket key (which routes through Steps 5/5b/6 instead), Step 4c does not run.
496
+ 2. **Board credentials are available.** Detect by reading `.clancy/.env` and confirming the configured board's credential variables are present (the same detection used by Step 1's three-state preflight). If `.clancy/.env` is absent or no board is configured, Step 4c is skipped.
497
+
498
+ If either gate fails, **skip Step 4c silently** and continue to Step 7 (Confirm and log). "Silently" means no warning, no log token, no stdout note — the absence of board credentials is the standalone-mode default, not an error condition. Subsequent slices add explicit log tokens for the conditions that DO warrant user-visible output (no pushable Source field, push failure, `--afk` without `--push`, etc.).
499
+
500
+ ### Best-effort semantics
501
+
502
+ Step 4c is **best-effort** and never rolls back the local marker. The `.clancy/plans/{stem}.approved` marker written in Step 4a is the source of truth for "this plan was approved" — a board push failure does not unwind it. Local state is authoritative; the board comment is a convenience surface. Subsequent slices wire the failure-logging and retry-command details.
503
+
504
+ ### Read the Source field from the plan file
505
+
506
+ Once both run-condition gates pass, read the `**Source:**` header line directly from the local plan file at `.clancy/plans/{stem}.md` (the same file Step 4a hashed and Step 4b read for the `**Brief:**` header). The plan file's header block — written by [`plan.md`](./plan.md) Step 5a — contains the brief's Source field value verbatim. Step 4c uses this header as the **single source of truth** for which board ticket (if any) the plan should be pushed to.
507
+
508
+ **Do NOT open the brief file** to look up Source in Step 4c. Step 4b already chases the brief filename out of the plan header to update its `planned:`/`approved:` row marker, but that second filesystem hop is unnecessary for Step 4c — the Source value Step 4b would find inside the brief is the same value already copied into the plan header. Reading from the plan file alone keeps Step 4c self-contained.
509
+
510
+ The line format is `**Source:** {value}` on a line by itself. Match it with a tolerant regex anchored to start-of-line, allowing arbitrary trailing whitespace:
511
+
512
+ ```
513
+ ^\*\*Source:\*\*\s+(.+?)\s*$
514
+ ```
515
+
516
+ Capture group 1 is the raw Source value — the next sub-step parses it into one of the three brief Source formats (bracketed key, inline-quoted, file path).
517
+
518
+ ### Handle a missing **Source:** header gracefully
519
+
520
+ If the plan file has no `**Source:**` header line (e.g. a hand-edited plan, or a plan generated before the Source header was added to the local plan template), Step 4c skips silently and continues to Step 7 — same semantics as the run-condition gates. No warning, no log token, no stdout note. The marker is still authoritative; the absence of a Source header just means there's no ticket to push to.
521
+
522
+ ### Parse the Source value (three brief formats)
523
+
524
+ Brief writes the Source field in **one of three formats** (per [`packages/brief/src/workflows/brief.md` lines ~806-810](../../../brief/src/workflows/brief.md)). Step 4c classifies the captured Source value into one of these three buckets:
525
+
526
+ 1. **Bracketed key — pushable.** The Source value matches `^\[(#?\d+|[A-Za-z][A-Za-z0-9]*-\d+|notion-[a-f0-9]{8}|[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\]\s+.+$`. Five accepted shapes inside the brackets: bare or `#`-prefixed integer (GitHub, Azure DevOps, Shortcut), `ABC-123` style alphanumeric prefix + dash + digits (Jira, Linear, Shortcut, with lowercase allowed), `notion-xxxxxxxx` short key, 32-character bare-hex Notion ID, or 36-character UUID with dashes. Examples: `[#50] Redesign settings page`, `[PROJ-200] Add customer portal`, `[ENG-42] Add real-time notifications`, `[notion-ab12cd34] Quarterly planning hub`, `[9f1c2d3e4a5b6789c0d1e2f3a4b5c6d7] Quarterly planning hub`, `[9f1c2d3e-4a5b-6789-c0d1-e2f3a4b5c6d7] Quarterly planning hub`. Extract the key from inside the brackets. **This is the only format that can be pushed to a board.** Continue to the key validation sub-step below — the validation step re-checks the extracted key against the configured board's regex (e.g. a 32-hex Notion ID extracted here will be re-validated against the Notion regex, not the Jira one).
527
+ 2. **Inline-quoted text — no ticket.** The Source value matches `^"[^"]+"$` (e.g. `"Add dark mode support"`). The user gave brief a free-text idea instead of a board ticket reference. There is no ticket to push to.
528
+ 3. **File path — no ticket.** The Source value matches anything else that is not a bracketed key (e.g. `docs/rfcs/auth-rework.md`). The user pointed brief at a local document. There is no ticket to push to.
529
+
530
+ The bracketed-key format is the **only** pushable Source format. Both inline-quoted and file-path formats route to the skip-no-ticket branch below.
531
+
532
+ ### Skip-no-ticket branch (inline-quoted or file-path Source)
533
+
534
+ When the parsed Source falls into the inline-quoted or file-path bucket, Step 4c **records the skip outcome** (without writing to `.clancy/progress.txt` yet) and continues to Step 7. The row Step 7 will write on its behalf:
535
+
536
+ ```
537
+ YYYY-MM-DD HH:MM | {stem} | BOARD_PUSH_SKIPPED_NO_TICKET | {source_format}
538
+ ```
539
+
540
+ Where `{source_format}` is the literal string `inline-quoted` or `file-path` (lowercase, hyphenated). All Step 4c outcome rows are written by Step 7 immediately after `LOCAL_APPROVE_PLAN`, so the order of rows in `.clancy/progress.txt` is always `LOCAL_APPROVE_PLAN` first, then the Step 4c outcome — two rows total per approval, one for the marker write and one for the Step 4c outcome. See "Step 4c outcome row" inside Step 7's local-mode log block for the full enumeration.
541
+
542
+ In the local-mode success block (rendered by Step 7), surface the skip as a non-warning info line under the marker write success:
543
+
544
+ ```
545
+ Note: source is {source_format} — no pushable ticket. Local marker only.
546
+ ```
547
+
548
+ This is informational, not an error. The plan IS approved; the absence of a board push is the expected outcome for non-bracketed Source values. After logging and surfacing the note, continue to Step 7 normally.
549
+
550
+ ### Validate the extracted key against the configured board
551
+
552
+ When the parsed Source is the bracketed-key form (or when `--ticket {KEY}` was passed as an override — see the decision matrix below), the extracted `{KEY}` value must be validated against the **configured board's** key pattern **before any push attempt is made**. Validation happens here, not after the curl request — a malformed key should never reach the network.
553
+
554
+ Detect the configured board by reading `.clancy/.env` (the same detection used by [Step 1's three-state preflight](#step-1--preflight-checks) and by Step 4c's credential gate above). This is a **single-board environment** — `.clancy/.env` configures exactly one board, and there is no cross-board disambiguation. Whichever board is configured, that board's regex is the only one Step 4c validates against.
555
+
556
+ Match the extracted `{KEY}` against the per-platform regex for the configured board:
557
+
558
+ | Platform | Regex | Example keys |
559
+ | ------------ | ---------------------------- | ---------------------------- |
560
+ | Jira | `^[A-Za-z][A-Za-z0-9]+-\d+$` | `PROJ-200`, `proj-200` |
561
+ | GitHub | `^#?\d+$` | `#50`, `50`, `#1234`, `1234` |
562
+ | Linear | `^[A-Za-z]{1,10}-\d+$` | `ENG-42`, `eng-42`, `CORE-7` |
563
+ | Azure DevOps | `^\d+$` | `12345` |
564
+ | Shortcut | `^(?:[A-Za-z]{1,5}-)?\d+$` | `8675`, `SC-8675`, `sc-123` |
565
+
566
+ **Notion** (kept out of the table because the regex contains a pipe that GFM table cells force-escape, breaking the regex when read literally): `^(?:notion-[a-f0-9]{8}|[a-f0-9]{32}|[a-f0-9-]{36})$`. This is a non-capturing alternation — the pipe is a real regex alternation, not a literal pipe — and it matches three accepted Notion key forms: a `notion-xxxxxxxx` short key (e.g. `notion-ab12cd34`), a 32-character bare-hex Notion ID (e.g. `abc123def456...`), or a 36-character UUID with dashes (e.g. `abc12345-def6-...`). Implementations must use this alternation form, **not** an escaped-pipe form like `^notion-[a-f0-9]{8}$\|^[a-f0-9]{32}$\|^[a-f0-9-]{36}$`, because in regex `\|` matches a literal pipe character, not alternation.
567
+
568
+ The six regexes are hard-coded inline above — there is no shared lookup table, and Step 4c does not consult any other workflow file for them. They mirror the broader Step 2 ticket-key formats (lowercase allowed, optional `#` on GitHub, optional `SC-` prefix on Shortcut, `notion-xxxxxxxx` short keys) so a key that Step 2 accepts is the same key Step 4c accepts. If a future board is added, this table is updated in the same PR.
569
+
570
+ ### Hard-error on key/board mismatch
571
+
572
+ If the extracted `{KEY}` does not match the configured `{board}`'s regex, Step 4c **hard-errors before attempting any push**. There is no second-chance: Step 4c never attempts a different platform's regex, never retries against an alternate board, and never silently skips the push. The mismatch is a user-actionable error — either the Source field in the brief was malformed, or the `--ticket` override targeted the wrong board.
573
+
574
+ Display the error:
575
+
576
+ ```
577
+ ✗ Step 4c: extracted key `{KEY}` does not match the configured `{board}` key format.
578
+ Expected pattern: {regex from the table above}
579
+ Source value: {raw Source line from the plan file}
580
+
581
+ The local marker is unchanged — your plan is still approved.
582
+ To retry with a corrected key:
583
+ /clancy:approve-plan {stem} --push --ticket {CORRECT_KEY}
584
+ ```
585
+
586
+ The local `.clancy/plans/{stem}.approved` marker **stays in place and is preserved**. Mismatch is a Step 4c failure, not a Step 4a failure — the marker write succeeded and the plan IS approved. The marker is authoritative; Step 4c never rolls back. After printing the error, **record the failure outcome** (without writing to `.clancy/progress.txt` yet) and continue to Step 7. The row Step 7 will write on its behalf:
587
+
588
+ ```
589
+ YYYY-MM-DD HH:MM | {stem} | BOARD_PUSH_FAILED | key-mismatch:{KEY}
590
+ ```
591
+
592
+ The local-mode success block still renders for the marker write in Step 7, with the mismatch error printed above it.
593
+
594
+ ### Flags governing Step 4c
595
+
596
+ Step 4c introduces two new flags on `/clancy:approve-plan` (in addition to the existing `--afk`):
597
+
598
+ - **`--push`** — skip the interactive prompt and push immediately. Combined with `--afk`, this is the unattended-automation path.
599
+ - **`--ticket {KEY}`** — override the Source auto-detect from the plan file. The override `{KEY}` wins over auto-detect and is what gets validated against the configured board's regex (see "Validate the extracted key against the configured board" above). Use this when the brief Source field is missing, ambiguous, or points at the wrong ticket.
600
+
601
+ The default interactive prompt is `[y/N]` with **default No** — Step 4c never surprise-writes to a board. A user who hits Enter without typing accepts the default (no push) and the plan is approved as a local-only operation.
602
+
603
+ The prompt text:
604
+
605
+ ```
606
+ Push approved plan to {KEY} as a comment? [y/N]
607
+ ```
608
+
609
+ `{KEY}` is the resolved ticket key — either auto-detected from the plan file's `**Source:**` line or supplied by `--ticket`.
610
+
611
+ ### Decision matrix
612
+
613
+ Step 4c's behaviour depends on three orthogonal axes: `--push`, `--afk`, and `--ticket`. The six meaningful cells:
614
+
615
+ | Cell | `--push` | `--afk` | `--ticket KEY` | Behaviour |
616
+ | --------------------------------- | -------- | ------- | -------------- | ----------------------------------------------------------------------------------------------- |
617
+ | **Interactive, no `--push`** | no | no | no | Auto-detect Source. Prompt `Push approved plan to {KEY} as a comment? [y/N]`. Default No. |
618
+ | **Interactive + `--ticket` only** | no | no | yes | Validate `KEY`. Prompt with the override `KEY`. Override wins over auto-detect. Default No. |
619
+ | **Interactive + `--push`** | yes | no | either | Skip prompt, push immediately. `--ticket` (if present) overrides auto-detect; otherwise auto. |
620
+ | **`--afk` without `--push`** | no | yes | either | Skip the push entirely. Log `LOCAL_ONLY` (see token below). `--ticket` is ignored in this cell. |
621
+ | **`--afk --push`** | yes | yes | no | Push without prompting. Auto-detect Source. |
622
+ | **`--afk --push --ticket KEY`** | yes | yes | yes | Push without prompting using the override `KEY`. |
623
+
624
+ The `--afk` without `--push` cell **records a `LOCAL_ONLY` outcome** (without writing to `.clancy/progress.txt` yet) so the user can see in their audit trail that an unattended run deliberately stayed local-only. The row Step 7 will write on its behalf:
625
+
626
+ ```
627
+ YYYY-MM-DD HH:MM | {stem} | LOCAL_ONLY | afk-without-push
628
+ ```
629
+
630
+ `--ticket` is **ignored** in the `--afk` without `--push` cell — there is no push attempted, so any override key has nothing to override. This is documented behaviour, not a silent quirk: the `--afk` mode's contract is "no surprises", and pushing because `--ticket` was set would surprise the user.
631
+
632
+ ### Two prompts in non-`--afk` flow is intentional
633
+
634
+ In non-`--afk` mode, the user sees **two prompts** during a single approve invocation: [Step 4 — Confirm](#step-4--confirm) prompts to confirm the approval (a local commitment), and Step 4c prompts to confirm the board push (a visible-to-others publication). These are semantically distinct decisions and are intentionally kept as two separate prompts. Merging them into a single "approve and push?" prompt would conflate two different commitments and make it impossible to approve locally without also publishing.
635
+
636
+ <!-- DUPLICATED REGION: the bytes between the curl-blocks:approve-plan-push:start/end anchors below are byte-identical to the canonical source in plan.md Step 5b. A drift-prevention test (workflows.test.ts) byte-compares the two regions and fails on mismatch. Edit one — update the other in the same commit. -->
637
+ <!-- curl-blocks:approve-plan-push:start -->
638
+
639
+ ### Jira — POST comment
640
+
641
+ ```bash
642
+ curl -s \
643
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
644
+ -X POST \
645
+ -H "Content-Type: application/json" \
646
+ -H "Accept: application/json" \
647
+ "$JIRA_BASE_URL/rest/api/3/issue/$TICKET_KEY/comment" \
648
+ -d '<ADF JSON body>'
649
+ ```
650
+
651
+ Construct ADF (Atlassian Document Format) JSON for the comment body. Key mappings:
652
+
653
+ - `## Heading` → `heading` node (level 2)
654
+ - `### Heading` → `heading` node (level 3)
655
+ - `- bullet` → `bulletList > listItem > paragraph`
656
+ - `- [ ] checkbox` → `taskList > taskItem` (state: "TODO")
657
+ - `| table |` → `table > tableRow > tableCell`
658
+ - `**bold**` → marks: `[{ "type": "strong" }]`
659
+ - `` `code` `` → marks: `[{ "type": "code" }]`
660
+
661
+ If ADF construction is too complex for a particular element, fall back to wrapping that section in a code block (`codeBlock` node).
662
+
663
+ ### GitHub — POST comment
664
+
665
+ ```bash
666
+ curl -s \
667
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
668
+ -H "X-GitHub-Api-Version: 2022-11-28" \
669
+ -X POST \
670
+ "https://api.github.com/repos/$GITHUB_REPO/issues/$ISSUE_NUMBER/comments" \
671
+ -d '{"body": "<markdown plan>"}'
672
+ ```
673
+
674
+ GitHub accepts Markdown directly — post the plan as-is.
675
+
676
+ ### Linear — commentCreate mutation
677
+
678
+ ```bash
679
+ curl -s \
680
+ -X POST \
681
+ -H "Content-Type: application/json" \
682
+ -H "Authorization: $LINEAR_API_KEY" \
683
+ "https://api.linear.app/graphql" \
684
+ -d '{"query": "mutation { commentCreate(input: { issueId: \"$ISSUE_ID\", body: \"<markdown plan>\" }) { success } }"}'
685
+ ```
686
+
687
+ Linear accepts Markdown directly.
688
+
689
+ ### Azure DevOps — POST comment
690
+
691
+ ```bash
692
+ curl -s \
693
+ -u ":$AZDO_PAT" \
694
+ -X POST \
695
+ -H "Content-Type: application/json" \
696
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$WORK_ITEM_ID/comments?api-version=7.1-preview.4" \
697
+ -d '{"text": "<html plan>"}'
698
+ ```
699
+
700
+ Azure DevOps work item comments use **HTML**, not markdown. Convert the plan markdown to HTML:
701
+
702
+ - `## Heading` → `<h2>Heading</h2>`
703
+ - `### Heading` → `<h3>Heading</h3>`
704
+ - `- bullet` → `<ul><li>bullet</li></ul>`
705
+ - `- [ ] checkbox` → `<ul><li>☐ checkbox</li></ul>`
706
+ - `| table |` → `<table><tr><td>...</td></tr></table>`
707
+ - `**bold**` → `<strong>bold</strong>`
708
+ - `` `code` `` → `<code>code</code>`
709
+ - Newlines → `<br>` or `<p>` tags
710
+
711
+ If HTML construction is too complex for a particular element, wrap that section in `<pre>` tags as fallback.
712
+
713
+ ### Shortcut — POST comment
714
+
715
+ ```bash
716
+ curl -s \
717
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
718
+ -H "Content-Type: application/json" \
719
+ -X POST \
720
+ "https://api.app.shortcut.com/api/v3/stories/$STORY_ID/comments" \
721
+ -d '{"text": "<markdown plan>"}'
722
+ ```
723
+
724
+ Shortcut accepts Markdown directly in comment text.
725
+
726
+ ### Notion — POST comment
727
+
728
+ ```bash
729
+ curl -s \
730
+ -H "Authorization: Bearer $NOTION_TOKEN" \
731
+ -H "Notion-Version: 2022-06-28" \
732
+ -X POST \
733
+ "https://api.notion.com/v1/comments" \
734
+ -d '{"parent": {"page_id": "$PAGE_ID"}, "rich_text": [{"type": "text", "text": {"content": "<plan text>"}}]}'
735
+ ```
736
+
737
+ **Notion limitation:** Comments use `rich_text` blocks, not markdown. For the plan content, use a single `text` block with the full plan as plain text. Notion will render it without markdown formatting. For better readability, consider splitting the plan into multiple `rich_text` blocks (one per section) with `annotations` for bold headings.
738
+
739
+ **Notion limitation:** The `rich_text` array has a **2000-character limit per text block**. If the plan exceeds 2000 characters, split it across multiple `rich_text` blocks within the same comment (each block up to 2000 chars). The total comment can contain many blocks.
740
+
741
+ <!-- curl-blocks:approve-plan-push:end -->
742
+
743
+ ### Push success — record the second audit row
744
+
745
+ When the curl returns a 2xx response, the comment is live on the source ticket. **Record a push-success outcome** with the resolved `{KEY}` (without writing to `.clancy/progress.txt` yet). Step 7 writes the second row immediately after `LOCAL_APPROVE_PLAN`:
746
+
747
+ ```
748
+ YYYY-MM-DD HH:MM | {stem} | LOCAL_APPROVE_PLAN_PUSH | {KEY}
749
+ ```
750
+
751
+ Two rows total per successful approve+push: one for the marker write (`LOCAL_APPROVE_PLAN`) and one for the board push (`LOCAL_APPROVE_PLAN_PUSH`). The two-row layout is unambiguous and survives partial-failure replay better than extending the single row with a `pushed:{KEY}` field — a downstream audit grep that wants "approvals that were also pushed to a board" can simply look for the second token.
752
+
753
+ The success row uses the same `{stem} | TOKEN | {detail}` column convention as every other progress.txt row in the file ([`plan.md` Step 6](./plan.md) for the canonical convention).
754
+
755
+ The existing local-mode success block in Step 7 already renders for the marker write — Step 4c's success path adds nothing extra to it on non-Notion platforms; the Notion-only flat-text note from Step 7 below renders only when the push target was Notion.
756
+
757
+ ### Handling push failure (HTTP non-2xx, network, timeout, dns, auth)
758
+
759
+ The curl request can fail in several ways: an HTTP non-2xx response (4xx auth/permission, 5xx server), a network-layer failure (DNS, TCP timeout, connection refused), or a credential rejection. **All push failures are best-effort** — the local `.clancy/plans/{stem}.approved` marker stays in place and is preserved. The marker is authoritative; Step 4c never rolls back. A push failure is a Step 4c failure, not a Step 4a failure: the plan IS approved, the board comment just didn't land.
760
+
761
+ Classify the failure into one of two buckets when logging:
762
+
763
+ - **HTTP status code** — when the curl returned a non-2xx response code (e.g. `403`, `404`, `429`, `500`, `502`). Use the literal numeric status code.
764
+ - **Error class** — when the curl failed before getting an HTTP response back (transport-layer failure). Use one of the lowercase tokens: `network` (generic transport failure or connection refused), `timeout` (the request exceeded the timeout), `dns` (hostname resolution failed), or `auth` (the platform returned an explicit credential-rejection signal that isn't a clean HTTP status).
765
+
766
+ Display the error to the user with both the failure detail and the retry command:
767
+
768
+ ```
769
+ ✗ Step 4c: failed to push approved plan to {KEY} on {board}.
770
+ Reason: {http_status_or_error_class}
771
+
772
+ The local marker is unchanged — your plan is still approved.
773
+ To retry the push:
774
+ /clancy:approve-plan {stem} --push --ticket {KEY}
775
+ ```
776
+
777
+ The retry command pattern is **exact** — `/clancy:approve-plan {stem} --push --ticket {KEY}` — so the user can copy-paste it without modification. The `--push` flag triggers the retry path (see Step 4a's `EEXIST + --push` fall-through above), and `--ticket {KEY}` re-supplies the resolved key in case the user is in a fresh shell where Source auto-detect would have to re-read the plan file.
778
+
779
+ **Record the failure outcome** (without writing to `.clancy/progress.txt` yet) with the classified reason. The row Step 7 will write on its behalf:
780
+
781
+ ```
782
+ YYYY-MM-DD HH:MM | {stem} | BOARD_PUSH_FAILED | {http_status_or_error_class}
783
+ ```
784
+
785
+ This is the same `BOARD_PUSH_FAILED` token used by the key-mismatch path above, with a different reason field. The two cases share the token because both represent "Step 4c tried to push and couldn't" — downstream tooling (Step 8 inventory, audit greps) treats them as a single failure category.
786
+
787
+ **Reason-field disambiguation contract.** The `BOARD_PUSH_FAILED` reason field is one of three disjoint shapes: a literal HTTP status code (`403`, `404`, `429`, `500`, ...), a lowercase error class from the closed set `network`/`timeout`/`dns`/`auth`, or the prefixed form `key-mismatch:{KEY}`. The `key-mismatch:` prefix is reserved — any future BOARD_PUSH_FAILED reason that introduces structured detail must use a similarly prefixed form (e.g. `rate-limit:60s`) so that downstream parsers can disambiguate by looking at the first token before any colon. HTTP status codes start with a digit and the error-class vocabulary is closed, so neither collides with a `letter:` prefix.
788
+
789
+ After printing the error and recording the outcome, continue to Step 7 (Confirm and log) — the local-mode success block still renders for the marker write, with the push-failure error printed above it. Push failure is **never** an exit-non-zero condition: the marker write succeeded, the audit trail captures the push failure (Step 7 writes the row), and the user has an actionable retry command.
790
+
791
+ After Step 4c completes (push attempted, push skipped, or gate failed), continue to Step 7 (Confirm and log).
472
792
 
473
793
  ---
474
794
 
@@ -1184,6 +1504,14 @@ Next:
1184
1504
 
1185
1505
  Do NOT print the Brief line when Step 4b warned and skipped (missing headers, no matching marker, or write error). In that case, also print the warning that Step 4b emitted under the success block but do not change the exit status — the plan IS approved regardless of whether the brief marker was updated.
1186
1506
 
1507
+ **Conditional Notion flat-text note:** when Step 4c successfully pushed to a Notion page (and **only** when the push target is Notion — not for the other five platforms), insert this informational line under the `Next:` block, after the existing bullets:
1508
+
1509
+ ```
1510
+ Note: Notion comments render as flat text — the plan structure won't be styled.
1511
+ ```
1512
+
1513
+ This is a one-time heads-up so Notion users aren't surprised that headings, bullets, tables, and code formatting were stripped on the comment side. The plan content itself is intact in the Notion comment; only the markdown styling was flattened by Notion's `rich_text` model. The note does not render for Jira, GitHub, Linear, Azure DevOps, or Shortcut pushes — those platforms preserve the original markdown formatting (or the platform-specific format conversion documented in Step 4c's curl blocks).
1514
+
1187
1515
  Append to `.clancy/progress.txt`:
1188
1516
 
1189
1517
  ```
@@ -1192,6 +1520,23 @@ YYYY-MM-DD HH:MM | {stem} | LOCAL_APPROVE_PLAN | sha256={first 12 hex}
1192
1520
 
1193
1521
  The `LOCAL_APPROVE_PLAN` token mirrors the `LOCAL_PLAN` / `LOCAL_REVISED` convention used by `/clancy:plan --from` (see [`plan.md` Step 6](./plan.md)). The token is for human audit only — any future plan-implementing tool reads the `.clancy/plans/{stem}.approved` marker directly rather than scanning `progress.txt` for approval state.
1194
1522
 
1523
+ #### Step 4c outcome row (write immediately after LOCAL_APPROVE_PLAN)
1524
+
1525
+ Step 4c does **not** write to `.clancy/progress.txt` itself — it records an outcome and Step 7 writes the row immediately after the `LOCAL_APPROVE_PLAN` row above. This guarantees that `LOCAL_APPROVE_PLAN` always lands first in the file (the marker write is the primary audit event) and the Step 4c outcome (push success, skip, or failure) lands second on the very next line.
1526
+
1527
+ The Step 4c outcome is one of these five possibilities — write the matching row if Step 4c ran AND produced an outcome, otherwise write nothing extra:
1528
+
1529
+ | Step 4c outcome | Row to write after `LOCAL_APPROVE_PLAN` |
1530
+ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
1531
+ | Push success (curl returned 2xx) | `YYYY-MM-DD HH:MM \| {stem} \| LOCAL_APPROVE_PLAN_PUSH \| {KEY}` |
1532
+ | Skip — Source was inline-quoted or file-path (no pushable ticket) | `YYYY-MM-DD HH:MM \| {stem} \| BOARD_PUSH_SKIPPED_NO_TICKET \| {source_format}` |
1533
+ | Skip — `--afk` was set without `--push` (deliberately stayed local-only) | `YYYY-MM-DD HH:MM \| {stem} \| LOCAL_ONLY \| afk-without-push` |
1534
+ | Failure — `{KEY}` did not match the configured board's regex (Step 4c hard-error) | `YYYY-MM-DD HH:MM \| {stem} \| BOARD_PUSH_FAILED \| key-mismatch:{KEY}` |
1535
+ | Failure — curl returned non-2xx, or transport-layer failure | `YYYY-MM-DD HH:MM \| {stem} \| BOARD_PUSH_FAILED \| {http_status_or_error_class}` |
1536
+ | Step 4c gate failed (no board credentials in `.clancy/.env`) OR Step 4c did not run at all | _(no second row — only the `LOCAL_APPROVE_PLAN` row above)_ |
1537
+
1538
+ The two-row layout is the contract every downstream audit grep and Step 8 inventory reader assumes: `LOCAL_APPROVE_PLAN` first, optional Step 4c outcome second, never out of order. If Step 4c was not invoked (e.g. board ticket key flow routes through Steps 5/5b/6 instead) or its credential gate failed silently, the file just has the single `LOCAL_APPROVE_PLAN` row.
1539
+
1195
1540
  ---
1196
1541
 
1197
1542
  ## Notes
@@ -898,6 +898,9 @@ After saving, skip to Step 6 (log). Do not run Step 5b (board posting) for `--fr
898
898
 
899
899
  **Guard:** Only run Step 5b when board credentials are available (terminal mode or standalone+board mode). In standalone mode (no `.clancy/.env`), skip this step entirely — the plan is still generated and printed to stdout in Step 4.
900
900
 
901
+ <!-- DUPLICATED REGION: the bytes between the curl-blocks:approve-plan-push:start/end anchors are the canonical source for the six platform comment-POST blocks. /clancy:approve-plan duplicates the same bytes into its Step 4c push sub-step. A drift-prevention test (workflows.test.ts) byte-compares the two regions and fails on mismatch. Edit one — update the other in the same commit. -->
902
+ <!-- curl-blocks:approve-plan-push:start -->
903
+
901
904
  ### Jira — POST comment
902
905
 
903
906
  ```bash
@@ -1000,6 +1003,8 @@ curl -s \
1000
1003
 
1001
1004
  **Notion limitation:** The `rich_text` array has a **2000-character limit per text block**. If the plan exceeds 2000 characters, split it across multiple `rich_text` blocks within the same comment (each block up to 2000 chars). The total comment can contain many blocks.
1002
1005
 
1006
+ <!-- curl-blocks:approve-plan-push:end -->
1007
+
1003
1008
  **On failure:** Print the plan to stdout and warn — do not lose the plan. The user can manually paste it.
1004
1009
 
1005
1010
  ```
@@ -1100,7 +1105,7 @@ Scan `.clancy/plans/` for all `.md` files. For each file, parse the local plan h
1100
1105
  - **Row** — value of the `**Row:**` line (e.g. `#2 — Add toggle component`). Display `?` if absent or empty.
1101
1106
  - **Source** — value of the `**Source:**` line (the brief's Source field). Display `?` if absent or empty.
1102
1107
  - **Planned** — value of the `**Planned:**` line (the YYYY-MM-DD planned date). Display `?` if absent or unparseable as a date.
1103
- - **Status** — derived live from the sibling `.approved` marker written by `/clancy:approve-plan` (PR 7b). The reader is filesystem-only — no `.clancy/.env` or board access required. For each plan file, follow this procedure (every numbered step either branches to a verdict or feeds the next step):
1108
+ - **Status** — derived live from the sibling `.approved` marker written by `/clancy:approve-plan`. The reader is filesystem-only — no `.clancy/.env` or board access required. For each plan file, follow this procedure (every numbered step either branches to a verdict or feeds the next step):
1104
1109
  1. Check whether `.clancy/plans/{plan-id}.approved` exists. If marker absent → `Planned` (verdict)
1105
1110
  2. Marker exists. Read and validate its `sha256=` line (the marker body is two `key=value` lines: `sha256={hex}` and `approved_at={ISO 8601}`) AND hash the current plan file's bytes the same way `/clancy:approve-plan` Step 4a does (lowercase hex SHA-256, no normalisation, no line-ending fix)
1106
1111
  3. If the marker exists but is malformed, missing its `sha256=` line, has a non-hex or wrong-length `sha256` value, or otherwise cannot be parsed deterministically → `Stale (re-approve)` (verdict). Print a hint after the table: `Marker .clancy/plans/{plan-id}.approved is malformed. Delete it and re-run /clancy:approve-plan {plan-id} to recreate.` Folding this into `Stale` (rather than inventing a new state) keeps the inventory deterministic — the user's remediation is the same as for a hash drift: delete the marker and re-approve
@@ -863,8 +863,16 @@ describe('approve-plan local marker (Step 4a)', () => {
863
863
  );
864
864
  });
865
865
 
866
- it('after writing the marker, Step 4a jumps to Step 7 (log) and skips board flow', () => {
867
- expect(content).toContain('jump to Step 7');
866
+ it('after writing the marker, Step 4a continues to Step 4c (PR 9 was Step 7 in PR 7b)', () => {
867
+ // PR 7b's "jump to Step 7" was rewritten in PR 9 slice 1 / DA H2 fix:
868
+ // the post-marker tail now hands off to Step 4c (Optional board push),
869
+ // which is best-effort and gates on board credentials. Steps 5/5b/6
870
+ // remain unreachable in plan-file stem mode regardless.
871
+ const afterMarkerIdx = content.indexOf('### After writing the marker');
872
+ const tailEnd = content.indexOf('---', afterMarkerIdx);
873
+ const tail = content.slice(afterMarkerIdx, tailEnd);
874
+ expect(tail).toMatch(/Step 4c/);
875
+ expect(tail).toMatch(/Steps 5, 5b, and 6[^.]*skipped/i);
868
876
  });
869
877
  });
870
878
 
@@ -1006,3 +1014,651 @@ describe('approve-plan board mode preserved unchanged', () => {
1006
1014
  expect(content).toContain('Notion');
1007
1015
  });
1008
1016
  });
1017
+
1018
+ // PR 9 — Slice 0/6: drift-prevention for the duplicated push curl blocks.
1019
+ // Slice 0 declared the start/end anchors in both files. Slice 6 promotes
1020
+ // this suite to a byte-equality check between the wrapped regions: the
1021
+ // canonical curl blocks live in plan.md Step 5b; approve-plan.md Step 4c
1022
+ // holds an identical duplicate.
1023
+ describe('approve-plan board push drift anchors (PR 9 Slice 0/6)', () => {
1024
+ const planContent = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
1025
+ const approveContent = readFileSync(
1026
+ new URL('approve-plan.md', import.meta.url),
1027
+ 'utf8',
1028
+ );
1029
+
1030
+ const startAnchor = '<!-- curl-blocks:approve-plan-push:start -->';
1031
+ const endAnchor = '<!-- curl-blocks:approve-plan-push:end -->';
1032
+
1033
+ const extractRegion = (source: string): string => {
1034
+ const startIdx = source.indexOf(startAnchor);
1035
+ const endIdx = source.indexOf(endAnchor);
1036
+ if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
1037
+ throw new Error('curl-blocks anchors missing or out of order');
1038
+ }
1039
+ return source.slice(startIdx + startAnchor.length, endIdx);
1040
+ };
1041
+
1042
+ it('plan.md declares the start anchor exactly once', () => {
1043
+ const matches = planContent.split(startAnchor).length - 1;
1044
+ expect(matches).toBe(1);
1045
+ });
1046
+
1047
+ it('plan.md declares the end anchor exactly once', () => {
1048
+ const matches = planContent.split(endAnchor).length - 1;
1049
+ expect(matches).toBe(1);
1050
+ });
1051
+
1052
+ it('plan.md start anchor precedes end anchor', () => {
1053
+ expect(planContent.indexOf(startAnchor)).toBeLessThan(
1054
+ planContent.indexOf(endAnchor),
1055
+ );
1056
+ });
1057
+
1058
+ it('approve-plan.md declares the start anchor exactly once', () => {
1059
+ const matches = approveContent.split(startAnchor).length - 1;
1060
+ expect(matches).toBe(1);
1061
+ });
1062
+
1063
+ it('approve-plan.md declares the end anchor exactly once', () => {
1064
+ const matches = approveContent.split(endAnchor).length - 1;
1065
+ expect(matches).toBe(1);
1066
+ });
1067
+
1068
+ it('approve-plan.md start anchor precedes end anchor', () => {
1069
+ expect(approveContent.indexOf(startAnchor)).toBeLessThan(
1070
+ approveContent.indexOf(endAnchor),
1071
+ );
1072
+ });
1073
+
1074
+ it('the duplicated curl-blocks region is byte-equal between the two files', () => {
1075
+ const planRegion = extractRegion(planContent);
1076
+ const approveRegion = extractRegion(approveContent);
1077
+ expect(approveRegion).toBe(planRegion);
1078
+ });
1079
+
1080
+ it('the duplicated region is non-empty (slice 6 populated it)', () => {
1081
+ const planRegion = extractRegion(planContent);
1082
+ expect(planRegion.length).toBeGreaterThan(100);
1083
+ // Sanity: region must contain at least one of the six platform headings.
1084
+ expect(planRegion).toMatch(/### Jira/);
1085
+ });
1086
+ });
1087
+
1088
+ // PR 9 — Slice 1: Step 4c heading + run conditions. Step 4c is the optional
1089
+ // board push, gated on (a) Step 4a having written a marker AND (b) board
1090
+ // credentials being present in .clancy/.env. Either gate failing → skip
1091
+ // silently and continue to Step 7. Step 4b's tail must now route through 4c
1092
+ // instead of jumping straight to Step 7.
1093
+ describe('approve-plan Step 4c run conditions (PR 9 Slice 1)', () => {
1094
+ const content = readFileSync(
1095
+ new URL('approve-plan.md', import.meta.url),
1096
+ 'utf8',
1097
+ );
1098
+
1099
+ it('defines Step 4c — Optional board push', () => {
1100
+ expect(content).toContain('## Step 4c — Optional board push (best-effort)');
1101
+ });
1102
+
1103
+ it('Step 4c is positioned after Step 4b and before Step 5', () => {
1104
+ const stepFourB = content.indexOf('## Step 4b — Update brief marker');
1105
+ const stepFourC = content.indexOf('## Step 4c — Optional board push');
1106
+ const stepFive = content.indexOf('## Step 5 — Update ticket description');
1107
+ expect(stepFourB).toBeGreaterThan(-1);
1108
+ expect(stepFourC).toBeGreaterThan(stepFourB);
1109
+ expect(stepFive).toBeGreaterThan(stepFourC);
1110
+ });
1111
+
1112
+ it('Step 4c gates on Step 4a having written the marker', () => {
1113
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1114
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1115
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1116
+ // Run-condition prose must reference the marker write from 4a.
1117
+ expect(fourCBody).toMatch(/Step 4a/);
1118
+ expect(fourCBody).toMatch(/marker/i);
1119
+ });
1120
+
1121
+ it('Step 4c gates on board credentials being available', () => {
1122
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1123
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1124
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1125
+ expect(fourCBody).toContain('.clancy/.env');
1126
+ expect(fourCBody).toMatch(/board credentials/i);
1127
+ });
1128
+
1129
+ it('Step 4c skips silently when either gate fails and continues to Step 7', () => {
1130
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1131
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1132
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1133
+ // "skip silently" + onward routing to Step 7.
1134
+ expect(fourCBody).toMatch(/skip[^.]*silent/i);
1135
+ expect(fourCBody).toMatch(/Step 7/);
1136
+ });
1137
+
1138
+ it('Step 4c is best-effort and never rolls back the local marker', () => {
1139
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1140
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1141
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1142
+ expect(fourCBody).toMatch(/best-effort/i);
1143
+ // Marker is authoritative; push failure must not roll back.
1144
+ expect(fourCBody).toMatch(
1145
+ /never[^.]*rolls?[- ]?back|do(?:es)? not rolls?[- ]?back/i,
1146
+ );
1147
+ });
1148
+
1149
+ it('Step 4b tail now routes through Step 4c instead of jumping to Step 7', () => {
1150
+ const fourBStart = content.indexOf('## Step 4b — Update brief marker');
1151
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1152
+ const fourBBody = content.slice(fourBStart, fourCStart);
1153
+ // 4b should hand off to 4c, not directly to Step 7.
1154
+ expect(fourBBody).toMatch(/Step 4c/);
1155
+ // The old "skip Steps 5, 5b, and 6 entirely" line must be gone — push
1156
+ // can still run via Step 4c when board credentials are present.
1157
+ expect(fourBBody).not.toMatch(/Skip Steps 5, 5b, and 6 entirely/);
1158
+ });
1159
+
1160
+ it('no stale "jump to Step 7" routing prose remains in Steps 4a/4b', () => {
1161
+ // DA review H2/L2: the stale routing prose ("jump to Step 7" / "Steps
1162
+ // 5, 5b, 6 are skipped entirely") existed in BOTH Step 4a's "After
1163
+ // writing the marker" subsection AND Step 4b's tail. Slice 1 only
1164
+ // rewrote 4b. This regression test scans the whole 4a→4c span and
1165
+ // asserts no leftover prose still implies a direct 4a/4b → 7 jump.
1166
+ const fourAStart = content.indexOf('## Step 4a — Write local marker');
1167
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1168
+ const span = content.slice(fourAStart, fourCStart);
1169
+ expect(span).not.toMatch(/jump to Step 7/i);
1170
+ expect(span).not.toMatch(/Skip Steps 5, 5b, and 6 entirely/);
1171
+ });
1172
+
1173
+ it('Step 4a "After writing the marker" continues to Step 4c', () => {
1174
+ // DA review H2: the Step 4a tail must explicitly hand off to 4c so
1175
+ // an LLM reading 4a in order doesn't internalise the old PR 7b flow.
1176
+ const afterMarkerIdx = content.indexOf('### After writing the marker');
1177
+ const nextHeadingIdx = content.indexOf('---', afterMarkerIdx);
1178
+ const afterMarkerBody = content.slice(afterMarkerIdx, nextHeadingIdx);
1179
+ expect(afterMarkerBody).toMatch(/Step 4c/);
1180
+ });
1181
+ });
1182
+
1183
+ // PR 9 — Slice 2: Source field is read directly from the local plan file's
1184
+ // **Source:** header (NOT chased through the brief file the way Step 4b does).
1185
+ // The plan header is the single source of truth for Step 4c.
1186
+ describe('approve-plan Step 4c source-field read (PR 9 Slice 2)', () => {
1187
+ const content = readFileSync(
1188
+ new URL('approve-plan.md', import.meta.url),
1189
+ 'utf8',
1190
+ );
1191
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1192
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1193
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1194
+
1195
+ it('Step 4c reads the **Source:** header from the plan file', () => {
1196
+ expect(fourCBody).toContain('**Source:**');
1197
+ expect(fourCBody).toContain('.clancy/plans/{stem}.md');
1198
+ });
1199
+
1200
+ it('Step 4c does NOT open the brief file to find Source', () => {
1201
+ // The brief-file path pattern (.clancy/briefs/) appears in Step 4b but
1202
+ // must not appear in Step 4c — slice 2 reads Source from the plan file
1203
+ // directly to avoid a second filesystem hop.
1204
+ expect(fourCBody).not.toContain('.clancy/briefs/');
1205
+ });
1206
+
1207
+ it('Step 4c documents the read order (after marker, before Source parse)', () => {
1208
+ // The read happens inside Step 4c body, after the run-condition gates
1209
+ // and before the (slice 3) format detection. Test the prose calls out
1210
+ // "read" and "**Source:**" together so the order is unambiguous.
1211
+ expect(fourCBody).toMatch(/read[^.]*\*\*Source:\*\*/i);
1212
+ });
1213
+
1214
+ it('Step 4c handles a missing **Source:** header gracefully', () => {
1215
+ // If the plan file has no **Source:** line, Step 4c must skip silently
1216
+ // (same semantics as the run-condition gates). No crash, no warning.
1217
+ expect(fourCBody).toMatch(
1218
+ /missing[^.]*\*\*Source:\*\*|no \*\*Source:\*\*/i,
1219
+ );
1220
+ expect(fourCBody).toMatch(/skip/i);
1221
+ });
1222
+ });
1223
+
1224
+ // PR 9 — Slice 3: three-format Source parser. Brief writes Source in one of
1225
+ // three formats (per brief.md ~806-810): [KEY] Title (bracketed, pushable),
1226
+ // "text" (inline quoted, no ticket), or path/to/file.md (file path, no
1227
+ // ticket). Bracketed → continue to slice 4 validation. Other two →
1228
+ // BOARD_PUSH_SKIPPED_NO_TICKET log token + stdout note + continue to Step 7.
1229
+ describe('approve-plan Step 4c source-format parser (PR 9 Slice 3)', () => {
1230
+ const content = readFileSync(
1231
+ new URL('approve-plan.md', import.meta.url),
1232
+ 'utf8',
1233
+ );
1234
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1235
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1236
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1237
+
1238
+ it('Step 4c documents the three brief Source formats', () => {
1239
+ // Bracketed key — the only pushable format.
1240
+ expect(fourCBody).toMatch(/\[#?\d+\]|\[[A-Z]+-\d+\]|\[\{KEY\}\]/);
1241
+ // Inline-quoted text format.
1242
+ expect(fourCBody).toMatch(/inline[- ]quoted|"[^"]+"/i);
1243
+ // File-path format.
1244
+ expect(fourCBody).toMatch(/file[- ]path|\.md/);
1245
+ });
1246
+
1247
+ it('Step 4c only pushes for the bracketed-key format', () => {
1248
+ // Prose must explicitly say bracketed is the only pushable format.
1249
+ expect(fourCBody).toMatch(
1250
+ /bracket[^.]*only[^.]*push|only[^.]*bracket[^.]*push/i,
1251
+ );
1252
+ });
1253
+
1254
+ it('Step 4c logs BOARD_PUSH_SKIPPED_NO_TICKET for non-bracketed Source', () => {
1255
+ expect(fourCBody).toContain('BOARD_PUSH_SKIPPED_NO_TICKET');
1256
+ });
1257
+
1258
+ it('Step 4c skip-token logs to .clancy/progress.txt', () => {
1259
+ // The skip token is a progress.txt row, not just stdout — same audit
1260
+ // surface as LOCAL_APPROVE_PLAN.
1261
+ expect(fourCBody).toMatch(
1262
+ /BOARD_PUSH_SKIPPED_NO_TICKET[\s\S]*progress\.txt|progress\.txt[\s\S]*BOARD_PUSH_SKIPPED_NO_TICKET/,
1263
+ );
1264
+ });
1265
+
1266
+ it('Step 4c skip-token row uses the {stem} | TOKEN | {detail} convention', () => {
1267
+ // Matches plan.md Step 6 / Step 7 LOCAL_APPROVE_PLAN convention:
1268
+ // {stem} | BOARD_PUSH_SKIPPED_NO_TICKET | {source_format}
1269
+ expect(fourCBody).toMatch(
1270
+ /\{stem\}\s*\\?\|\s*BOARD_PUSH_SKIPPED_NO_TICKET\s*\\?\|\s*\{source_format\}/,
1271
+ );
1272
+ });
1273
+
1274
+ it('Step 4c surfaces the skip in the local-mode success block', () => {
1275
+ // Stdout note so the user knows why no push happened. Not a warning —
1276
+ // an info line under the marker write success.
1277
+ expect(fourCBody).toMatch(/stdout|success block|local[- ]mode/i);
1278
+ expect(fourCBody).toMatch(/no pushable[^.]*ticket|no ticket[^.]*push/i);
1279
+ });
1280
+
1281
+ it('Step 4c continues to Step 7 after a skip-no-ticket', () => {
1282
+ // After logging the skip token, flow continues normally — not an error.
1283
+ const skipRegion = fourCBody.slice(
1284
+ fourCBody.indexOf('BOARD_PUSH_SKIPPED_NO_TICKET'),
1285
+ );
1286
+ expect(skipRegion).toMatch(/Step 7|continue|proceed/i);
1287
+ });
1288
+ });
1289
+
1290
+ // PR 9 — Slice 4: per-platform key validation. Once Source has been parsed
1291
+ // into a bracketed-key form, the extracted key must match the configured
1292
+ // board's regex BEFORE any push attempt. Six platforms, six inline regexes,
1293
+ // hard-error on mismatch — no second-chance fallback. Single-board env, no
1294
+ // cross-board disambiguation.
1295
+ describe('approve-plan Step 4c key validation (PR 9 Slice 4)', () => {
1296
+ const content = readFileSync(
1297
+ new URL('approve-plan.md', import.meta.url),
1298
+ 'utf8',
1299
+ );
1300
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1301
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1302
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1303
+
1304
+ it('Step 4c declares all six per-platform key regexes inline', () => {
1305
+ // Each assertion requires a literal `\d` (escaped backslash + d) so a
1306
+ // doc that accidentally drops the backslash from the regex table fails
1307
+ // the test instead of silently passing on a bare `d`. The Jira and
1308
+ // Linear assertions match on the [A-Za-z] character class (their first
1309
+ // distinguishing feature — lowercase allowed for parity with Step 2)
1310
+ // and tighten with \\d for the digits.
1311
+ // Jira: ^[A-Za-z][A-Za-z0-9]+-\d+$
1312
+ expect(fourCBody).toMatch(/Jira[\s\S]{0,200}\[A-Za-z\]\[A-Za-z0-9\]\+-\\d/);
1313
+ // GitHub: ^#?\d+$ — optional # so bare numbers are accepted (Step 2 parity)
1314
+ expect(fourCBody).toMatch(/GitHub[\s\S]{0,200}#\?\\d/);
1315
+ // Linear: ^[A-Za-z]{1,10}-\d+$
1316
+ expect(fourCBody).toMatch(/Linear[\s\S]{0,200}\[A-Za-z\]\{1,10\}-\\d/);
1317
+ // Azure DevOps: ^\d+$
1318
+ expect(fourCBody).toMatch(/Azure DevOps[\s\S]{0,200}\\d/);
1319
+ // Shortcut: ^(?:[A-Za-z]{1,5}-)?\d+$ — optional alpha prefix
1320
+ expect(fourCBody).toMatch(
1321
+ /Shortcut[\s\S]{0,200}\(\?:\[A-Za-z\]\{1,5\}-\)\?\\d/,
1322
+ );
1323
+ // Notion: ^(?:notion-[a-f0-9]{8}|[a-f0-9]{32}|[a-f0-9-]{36})$
1324
+ // Match the full alternation structure literally so a partially deleted
1325
+ // or malformed Notion regex (e.g. dropped one of the three branches,
1326
+ // dropped the non-capturing group, lost the anchors) fails the test
1327
+ // instead of passing on incidental notion- / [a-f0-9] tokens elsewhere.
1328
+ expect(fourCBody).toMatch(
1329
+ /Notion[\s\S]{0,400}\^\(\?:notion-\[a-f0-9\]\{8\}\|\[a-f0-9\]\{32\}\|\[a-f0-9-\]\{36\}\)\$/,
1330
+ );
1331
+ });
1332
+
1333
+ it('Step 4c selects the regex from the configured board (single-board env)', () => {
1334
+ // Detection mirrors Step 1's three-state preflight — same .env reads.
1335
+ expect(fourCBody).toMatch(/configured board|board.*configured/i);
1336
+ expect(fourCBody).toMatch(/single[- ]board/i);
1337
+ });
1338
+
1339
+ it('Step 4c validates the extracted key BEFORE any push attempt', () => {
1340
+ // Validation must happen before the curl region — not after.
1341
+ const validateIdx = fourCBody.search(/validat/i);
1342
+ const anchorIdx = fourCBody.indexOf(
1343
+ '<!-- curl-blocks:approve-plan-push:start -->',
1344
+ );
1345
+ expect(validateIdx).toBeGreaterThan(-1);
1346
+ expect(anchorIdx).toBeGreaterThan(-1);
1347
+ expect(validateIdx).toBeLessThan(anchorIdx);
1348
+ });
1349
+
1350
+ it('Step 4c hard-errors on key/board mismatch (no fallback)', () => {
1351
+ // Mismatch is a hard error — not a silent skip, not a warning. The
1352
+ // user explicitly asked to push something the board can't accept.
1353
+ expect(fourCBody).toMatch(/hard[- ]error|hard error/i);
1354
+ // No second-chance — the test must reject prose suggesting a fallback
1355
+ // platform attempt or a "try the other regex" path.
1356
+ expect(fourCBody).not.toMatch(/try the other|fall ?back to[^.]*platform/i);
1357
+ });
1358
+
1359
+ it('Step 4c key-mismatch error names the key and the configured board', () => {
1360
+ // Error message must be actionable: which key, which board.
1361
+ expect(fourCBody).toMatch(
1362
+ /\{KEY\}[\s\S]{0,400}\{board\}|\{board\}[\s\S]{0,400}\{KEY\}/,
1363
+ );
1364
+ });
1365
+
1366
+ it('Step 4c key-mismatch DOES NOT roll back the local marker', () => {
1367
+ // Same best-effort rule as push failure — the marker is authoritative.
1368
+ // A bad --ticket override should never undo a successful Step 4a.
1369
+ const mismatchRegion = fourCBody.slice(fourCBody.search(/hard[- ]error/i));
1370
+ expect(mismatchRegion).toMatch(
1371
+ /marker[^.]*(stays|preserved|kept|authoritative)|never[^.]*rolls?[- ]?back/i,
1372
+ );
1373
+ });
1374
+ });
1375
+
1376
+ // PR 9 — Slice 5: decision matrix. Three orthogonal axes — --push, --afk,
1377
+ // --ticket — produce six meaningful cells. Default prompt is [y/N] (default
1378
+ // No, never surprise-write). Two interactive prompts in non-afk flow is
1379
+ // intentional (Step 4 confirms approval, Step 4c confirms push).
1380
+ describe('approve-plan Step 4c decision matrix (PR 9 Slice 5)', () => {
1381
+ const content = readFileSync(
1382
+ new URL('approve-plan.md', import.meta.url),
1383
+ 'utf8',
1384
+ );
1385
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1386
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1387
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1388
+
1389
+ it('Step 4c declares the new --push and --ticket flags', () => {
1390
+ expect(fourCBody).toContain('`--push`');
1391
+ expect(fourCBody).toContain('`--ticket');
1392
+ });
1393
+
1394
+ it('Step 4c interactive prompt defaults to [y/N] (default No)', () => {
1395
+ expect(fourCBody).toContain('[y/N]');
1396
+ expect(fourCBody).toMatch(/default[^.]*No|never surprise[- ]write/i);
1397
+ });
1398
+
1399
+ it('Step 4c interactive prompt text names the resolved KEY', () => {
1400
+ // The prompt must show which ticket the push will hit.
1401
+ expect(fourCBody).toMatch(
1402
+ /Push approved plan to[^.]*\{KEY\}[^.]*comment\?[^.]*\[y\/N\]/,
1403
+ );
1404
+ });
1405
+
1406
+ it('Step 4c covers interactive without --push (prompt the user)', () => {
1407
+ expect(fourCBody).toMatch(
1408
+ /interactive[^.]*no `--push`|no `--push`[^.]*interactive/i,
1409
+ );
1410
+ // Interactive cell explicitly prompts.
1411
+ expect(fourCBody).toMatch(/prompt[\s\S]{0,300}\[y\/N\]/);
1412
+ });
1413
+
1414
+ it('Step 4c covers interactive + --push (skip prompt and push)', () => {
1415
+ expect(fourCBody).toMatch(
1416
+ /interactive[^.]*`--push`[^.]*skip[^.]*prompt|`--push`[^.]*skip[^.]*prompt[^.]*push/i,
1417
+ );
1418
+ });
1419
+
1420
+ it('Step 4c covers --afk without --push (LOCAL_ONLY skip)', () => {
1421
+ expect(fourCBody).toContain('LOCAL_ONLY');
1422
+ expect(fourCBody).toMatch(
1423
+ /--afk[^.]*without[^.]*--push|--afk[^.]*no[^.]*--push/i,
1424
+ );
1425
+ });
1426
+
1427
+ it('Step 4c LOCAL_ONLY token logs to .clancy/progress.txt with stem', () => {
1428
+ // {stem} | TOKEN | {detail} convention:
1429
+ // {stem} | LOCAL_ONLY | afk-without-push
1430
+ expect(fourCBody).toMatch(
1431
+ /\{stem\}\s*\\?\|\s*LOCAL_ONLY\s*\\?\|\s*afk-without-push/,
1432
+ );
1433
+ });
1434
+
1435
+ it('Step 4c covers --afk --push (push without prompting)', () => {
1436
+ expect(fourCBody).toMatch(
1437
+ /--afk\s*--push|--afk[^.]*--push[^.]*without[^.]*prompt/i,
1438
+ );
1439
+ });
1440
+
1441
+ it('Step 4c covers --ticket KEY override (auto-detect bypassed)', () => {
1442
+ expect(fourCBody).toMatch(/--ticket[^.]*override|override[^.]*--ticket/i);
1443
+ // --ticket wins over Source auto-detect.
1444
+ expect(fourCBody).toMatch(
1445
+ /wins over[^.]*auto[- ]detect|override[^.]*auto[- ]detect/i,
1446
+ );
1447
+ });
1448
+
1449
+ it('Step 4c documents --ticket is ignored under --afk-without-push', () => {
1450
+ // The LOCAL_ONLY skip cell ignores --ticket entirely (no push happens).
1451
+ const localOnlyRegion = fourCBody.slice(fourCBody.indexOf('LOCAL_ONLY'));
1452
+ expect(localOnlyRegion.slice(0, 800)).toMatch(
1453
+ /--ticket[^.]*ignored|ignored[^.]*--ticket/i,
1454
+ );
1455
+ });
1456
+
1457
+ it('Step 4c calls out the two-prompt flow as intentional', () => {
1458
+ // Step 4 confirms approval, Step 4c confirms push — semantically distinct.
1459
+ expect(fourCBody).toMatch(/two[- ]prompt|two prompts|second prompt/i);
1460
+ expect(fourCBody).toMatch(/intentional|distinct|semantically/i);
1461
+ });
1462
+ });
1463
+
1464
+ // PR 9 — Slice 7: failure semantics for actual push failures (HTTP non-2xx,
1465
+ // network/timeout/dns/auth). Marker stays, BOARD_PUSH_FAILED logged, exact
1466
+ // retry command printed. No rollback.
1467
+ describe('approve-plan Step 4c push failure semantics (PR 9 Slice 7)', () => {
1468
+ const content = readFileSync(
1469
+ new URL('approve-plan.md', import.meta.url),
1470
+ 'utf8',
1471
+ );
1472
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1473
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1474
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1475
+
1476
+ it('Step 4c handles push failure as best-effort (marker preserved)', () => {
1477
+ // Distinct from the slice 4 key-mismatch path — this is the
1478
+ // post-validation, post-curl HTTP failure path.
1479
+ expect(fourCBody).toMatch(/push fail/i);
1480
+ expect(fourCBody).toMatch(
1481
+ /marker[^.]*(stays|preserved|kept|authoritative)/i,
1482
+ );
1483
+ });
1484
+
1485
+ it('Step 4c logs BOARD_PUSH_FAILED with stem and status class', () => {
1486
+ // {stem} | TOKEN | {detail} convention:
1487
+ // {stem} | BOARD_PUSH_FAILED | {http_status_or_error_class}
1488
+ expect(fourCBody).toContain('BOARD_PUSH_FAILED');
1489
+ expect(fourCBody).toMatch(
1490
+ /\{stem\}\s*\\?\|\s*BOARD_PUSH_FAILED\s*\\?\|\s*\{http_status_or_error_class\}/,
1491
+ );
1492
+ });
1493
+
1494
+ it('Step 4c defines the error-class vocabulary', () => {
1495
+ // Locked spec: HTTP status code on non-2xx, OR lowercase error class
1496
+ // on transport failure: network, timeout, dns, auth.
1497
+ expect(fourCBody).toMatch(/network/);
1498
+ expect(fourCBody).toMatch(/timeout/);
1499
+ expect(fourCBody).toMatch(/dns/);
1500
+ expect(fourCBody).toMatch(/auth/);
1501
+ // HTTP status path also documented.
1502
+ expect(fourCBody).toMatch(/HTTP[^.]*status|status code|non-2xx/i);
1503
+ });
1504
+
1505
+ it('Step 4c prints the exact retry command on push failure', () => {
1506
+ // Exact command pattern: /clancy:approve-plan {stem} --push --ticket {KEY}
1507
+ expect(fourCBody).toMatch(
1508
+ /\/clancy:approve-plan\s+\{stem\}\s+--push\s+--ticket\s+\{KEY\}/,
1509
+ );
1510
+ });
1511
+
1512
+ it('Step 4c push-failure path continues to Step 7 (does not exit non-zero)', () => {
1513
+ // Push failure is best-effort — workflow still continues to render the
1514
+ // local-mode success block in Step 7.
1515
+ const failRegion = fourCBody.slice(fourCBody.search(/push fail/i));
1516
+ expect(failRegion).toMatch(/Step 7|continue|proceed/i);
1517
+ });
1518
+
1519
+ it('Step 4c push-failure NEVER rolls back the marker', () => {
1520
+ expect(fourCBody).toMatch(
1521
+ /never[^.]*rolls?[- ]?back|do(?:es)? not rolls?[- ]?back/i,
1522
+ );
1523
+ });
1524
+
1525
+ it('Step 4c logs LOCAL_APPROVE_PLAN_PUSH success row with stem and KEY', () => {
1526
+ // DA review H1: the success path needs a second progress.txt row
1527
+ // distinct from LOCAL_APPROVE_PLAN. Two-row audit is unambiguous.
1528
+ expect(fourCBody).toContain('LOCAL_APPROVE_PLAN_PUSH');
1529
+ expect(fourCBody).toMatch(
1530
+ /\{stem\}\s*\\?\|\s*LOCAL_APPROVE_PLAN_PUSH\s*\\?\|\s*\{KEY\}/,
1531
+ );
1532
+ });
1533
+
1534
+ it('Step 4c documents the BOARD_PUSH_FAILED reason-field disambiguation', () => {
1535
+ // DA review L1: HTTP status, error-class, and key-mismatch:{KEY} share
1536
+ // the token. The contract that disambiguates them must be explicit.
1537
+ expect(fourCBody).toMatch(
1538
+ /key-mismatch:[^.]*reserved|reserved[^.]*key-mismatch/i,
1539
+ );
1540
+ });
1541
+ });
1542
+
1543
+ // PR 9 — Slice 8: retry path. EEXIST + --push falls through to Step 4c
1544
+ // instead of stopping. Without --push, EEXIST stops as today (PR 7b).
1545
+ // This is the only mechanism to re-attempt a failed push — no new flag,
1546
+ // no marker deletion.
1547
+ describe('approve-plan Step 4a EEXIST retry path (PR 9 Slice 8)', () => {
1548
+ const content = readFileSync(
1549
+ new URL('approve-plan.md', import.meta.url),
1550
+ 'utf8',
1551
+ );
1552
+
1553
+ // Step 4a's EEXIST handling section is the load-bearing region for slice 8.
1554
+ const eexistStart = content.indexOf('### Handle EEXIST');
1555
+ const eexistEnd = content.indexOf('### Marker is the gate');
1556
+ const eexistBody = content.slice(eexistStart, eexistEnd);
1557
+
1558
+ it('Step 4a EEXIST handling references the --push retry path', () => {
1559
+ expect(eexistBody).toContain('--push');
1560
+ });
1561
+
1562
+ it('EEXIST + --push falls through to Step 4c (does not stop)', () => {
1563
+ expect(eexistBody).toMatch(
1564
+ /EEXIST[\s\S]*--push[\s\S]*(falls?[- ]through|continue)[\s\S]*Step 4c/i,
1565
+ );
1566
+ });
1567
+
1568
+ it('EEXIST without --push still stops with the PR 7b message', () => {
1569
+ // PR 7b's existing behaviour preserved — bare EEXIST without --push
1570
+ // still hits the "Plan already approved" stop branch.
1571
+ expect(eexistBody).toMatch(
1572
+ /without[^.]*--push|no `--push`|`--push`\s+is\s+NOT\s+set|NOT\s+set/i,
1573
+ );
1574
+ expect(eexistBody).toContain('Plan already approved');
1575
+ });
1576
+
1577
+ it('EEXIST + --push does NOT re-write the marker', () => {
1578
+ // The marker is preserved as-is. The retry only re-runs Step 4c.
1579
+ expect(eexistBody).toMatch(
1580
+ /(?:does not|never|do not|is not|not) re[- ]?written|marker[^a-z]*(stays|preserved|unchanged)/i,
1581
+ );
1582
+ });
1583
+
1584
+ it('EEXIST + --push retry skips Step 4b (brief marker already updated)', () => {
1585
+ // Step 4b ran on the original approval; the brief marker is already
1586
+ // updated. The retry path goes EEXIST → Step 4c directly, not via 4b.
1587
+ expect(eexistBody).toMatch(/skip[^.]*Step 4b|Step 4b[^.]*skip/i);
1588
+ });
1589
+
1590
+ it('Step 4c documents the retry entry path from EEXIST', () => {
1591
+ // Symmetry test: Step 4c should mention the retry entry path from
1592
+ // 4a's EEXIST branch so a reader of 4c knows where re-runs come from.
1593
+ const fourCStart = content.indexOf('## Step 4c — Optional board push');
1594
+ const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
1595
+ const fourCBody = content.slice(fourCStart, fourCEnd);
1596
+ expect(fourCBody).toMatch(
1597
+ /EEXIST[^.]*--push|--push[^.]*EEXIST|retry[^.]*Step 4a/i,
1598
+ );
1599
+ });
1600
+ });
1601
+
1602
+ // PR 9 — Slice 9: Notion flat-text note + commands/approve-plan.md flag docs.
1603
+ // Notion comments render as flat text — surface in the local-mode success
1604
+ // block so Notion users aren't surprised. Command file gets the new flag
1605
+ // surface (--push, --ticket).
1606
+ describe('approve-plan Step 7 Notion flat-text note (PR 9 Slice 9)', () => {
1607
+ const content = readFileSync(
1608
+ new URL('approve-plan.md', import.meta.url),
1609
+ 'utf8',
1610
+ );
1611
+ const stepSevenStart = content.indexOf('## Step 7');
1612
+ const stepSevenBody = content.slice(stepSevenStart);
1613
+
1614
+ it('Step 7 local-mode block surfaces a Notion flat-text note', () => {
1615
+ expect(stepSevenBody).toMatch(
1616
+ /Notion[^.]*flat[- ]text|flat[- ]text[^.]*Notion/i,
1617
+ );
1618
+ });
1619
+
1620
+ it('Notion note is conditional on the push target (only when Notion)', () => {
1621
+ expect(stepSevenBody).toMatch(
1622
+ /only[^.]*Notion|when[^.]*Notion|Notion[^.]*only/i,
1623
+ );
1624
+ });
1625
+
1626
+ it('Notion note explicitly says structure will not be styled', () => {
1627
+ expect(stepSevenBody).toMatch(
1628
+ /won.?t be styled|no[^.]*styling|plain text/i,
1629
+ );
1630
+ });
1631
+ });
1632
+
1633
+ describe('approve-plan command file flag surface (PR 9 Slice 9)', () => {
1634
+ const content = readFileSync(
1635
+ new URL('../commands/approve-plan.md', import.meta.url),
1636
+ 'utf8',
1637
+ );
1638
+
1639
+ it('command file documents --push flag', () => {
1640
+ expect(content).toContain('`--push`');
1641
+ });
1642
+
1643
+ it('command file documents --ticket flag', () => {
1644
+ expect(content).toContain('`--ticket');
1645
+ });
1646
+
1647
+ it('command file --push doc references board push target', () => {
1648
+ const pushIdx = content.indexOf('`--push`');
1649
+ expect(pushIdx).toBeGreaterThan(-1);
1650
+ const pushParagraph = content.slice(pushIdx, pushIdx + 400);
1651
+ expect(pushParagraph).toMatch(/push|board/i);
1652
+ });
1653
+
1654
+ it('command file --ticket doc references KEY override of Source', () => {
1655
+ const ticketIdx = content.indexOf('`--ticket');
1656
+ expect(ticketIdx).toBeGreaterThan(-1);
1657
+ const ticketParagraph = content.slice(ticketIdx, ticketIdx + 400);
1658
+ expect(ticketParagraph).toMatch(/override|Source|KEY/i);
1659
+ });
1660
+
1661
+ it('command file preserves --afk from PR 7b', () => {
1662
+ expect(content).toContain('`--afk`');
1663
+ });
1664
+ });