@chief-clancy/plan 0.4.1 → 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/README.md +4 -2
- package/package.json +1 -1
- package/src/commands/approve-plan.md +4 -2
- package/src/workflows/approve-plan.md +368 -16
- package/src/workflows/plan.md +8 -3
- package/src/workflows/workflows.test.ts +671 -10
package/README.md
CHANGED
|
@@ -101,9 +101,11 @@ sha256=d2c9f3a1b4e6c8f09123456789abcdef0123456789abcdef0123456789abcd
|
|
|
101
101
|
approved_at=2026-04-08T22:30:00Z
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
The full 64-character hex hash is what `.approved` actually stores —
|
|
104
|
+
The full 64-character hex hash is what `.approved` actually stores — any plan-implementing tool reads the marker, hashes the current plan file the same way, and blocks implementation on any mismatch.
|
|
105
105
|
|
|
106
|
-
The marker is the gate
|
|
106
|
+
The marker is the gate plan-implementing tools check before applying changes — if the plan file is edited after approval, the SHA mismatch blocks implementation until you re-approve. A dedicated `/clancy:implement-from` slash command was originally scoped for the plan package but is **deferred** until `@chief-clancy/dev` is extracted (the slash command is convenience, not capability — Claude Code can already do the SHA gate + structured plan parse via natural-language instruction). In the meantime, you can apply approved plans by asking Claude Code directly — for example: `Implement .clancy/plans/add-dark-mode-2.md, verifying the .approved marker's sha256 first` — or by installing the full Clancy pipeline (`npx chief-clancy`) for the board-driven flow.
|
|
107
|
+
|
|
108
|
+
Clancy also tries to update the brief file's `<!-- planned:1,2 -->` marker to `<!-- approved:1 planned:1,2 -->` so `/clancy:plan --list` knows which rows are approved, but that brief-marker update is best-effort and may warn-and-skip if the expected brief metadata or matching marker is missing.
|
|
107
109
|
|
|
108
110
|
### Standalone+board (board credentials but no full pipeline)
|
|
109
111
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Approve a Clancy implementation plan. Behaviour depends on the install context and the argument:
|
|
4
4
|
|
|
5
|
-
- **Local plan file:** `/clancy:approve-plan add-dark-mode-2` — write a `.clancy/plans/{stem}.approved` marker (with the plan's SHA-256 + approval timestamp) and update the source brief's row marker. The marker is the gate `/clancy:implement-from`
|
|
5
|
+
- **Local plan file:** `/clancy:approve-plan add-dark-mode-2` — write a `.clancy/plans/{stem}.approved` marker (with the plan's SHA-256 + approval timestamp) and update the source brief's row marker. 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)
|
|
6
6
|
- **Board ticket:** `/clancy:approve-plan PROJ-123` — promote an approved plan from a ticket comment to the ticket description, edit the plan comment with an approval note, swap the ticket labels (`CLANCY_LABEL_PLAN` → `CLANCY_LABEL_BUILD`, both with sensible defaults), and — only if `CLANCY_STATUS_PLANNED` is configured — transition the ticket status. Requires board credentials. Runs in both standalone+board and terminal modes (the full pipeline is not required for the board transport flow itself; it is only required for downstream `/clancy:implement` to consume the result)
|
|
7
7
|
- **No argument:** auto-select the oldest unapproved plan. In standalone mode this scans `.clancy/plans/`; in standalone+board / terminal mode it scans `.clancy/progress.txt` for board tickets
|
|
8
8
|
|
|
@@ -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.
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
Approve a Clancy implementation plan. Behaviour depends on the install context:
|
|
6
6
|
|
|
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 `/clancy:implement-from`
|
|
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
|
|
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 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)
|
|
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)
|
|
117
|
+
If valid: proceed with that key. The board transport flow runs (Steps 3-7 below).
|
|
118
118
|
|
|
119
119
|
**No argument:**
|
|
120
120
|
|
|
@@ -360,7 +360,7 @@ After confirmation in plan-file stem mode, jump to Step 4a (local marker write).
|
|
|
360
360
|
|
|
361
361
|
## Step 4a — Write local marker
|
|
362
362
|
|
|
363
|
-
Run this step instead of Steps 5, 5b, 6 when the resolved argument was a plan-file stem (standalone mode, or standalone+board / terminal mode where Step 2 found a matching plan file). Write a `.clancy/plans/{stem}.approved` marker that gates
|
|
363
|
+
Run this step instead of Steps 5, 5b, 6 when the resolved argument was a plan-file stem (standalone mode, or standalone+board / terminal mode where Step 2 found a matching plan file). Write a `.clancy/plans/{stem}.approved` marker that gates plan implementation (see "Marker is the gate for future implementation tooling" below for the deferral context).
|
|
364
364
|
|
|
365
365
|
### Compute the SHA-256
|
|
366
366
|
|
|
@@ -370,7 +370,7 @@ Run this step instead of Steps 5, 5b, 6 when the resolved argument was a plan-fi
|
|
|
370
370
|
2. Compute the SHA-256 hash of those bytes — no normalisation (no line-ending fix, no trailing-whitespace strip, no BOM removal). Hex-encode lowercase.
|
|
371
371
|
3. **Then** (only after the hash is computed) open the `.approved` marker for exclusive create as described below.
|
|
372
372
|
|
|
373
|
-
The `.approved` file is **never** included in the hash — only `.clancy/plans/{stem}.md` is hashed, and only its on-disk byte content at the moment of step 1.
|
|
373
|
+
The `.approved` file is **never** included in the hash — only `.clancy/plans/{stem}.md` is hashed, and only its on-disk byte content at the moment of step 1. The marker is designed so a future implementer (deferred to `@chief-clancy/dev`) can re-read the same plan file, hash it the same way, and compare to the `sha256=` value stored in the marker. Until that consumer ships, the gate is enforced manually: a user (or Claude Code via natural-language instruction) reads the marker, hashes the plan file, and refuses to apply the plan on mismatch. Any divergence (re-edit, line-ending change, trailing whitespace tweak) is detectable.
|
|
374
374
|
|
|
375
375
|
### Write the marker file with O_EXCL
|
|
376
376
|
|
|
@@ -381,11 +381,23 @@ sha256={hex sha256 of the plan file at approval time}
|
|
|
381
381
|
approved_at={ISO 8601 UTC timestamp, e.g. 2026-04-08T22:30:00Z}
|
|
382
382
|
```
|
|
383
383
|
|
|
384
|
-
Two `key=value` lines, each terminated with `\n`. No JSON, no extra whitespace, no comments.
|
|
384
|
+
Two `key=value` lines, each terminated with `\n`. No JSON, no extra whitespace, no comments. A future implementer (deferred to `@chief-clancy/dev`) will parse this with a tolerant `^(sha256|approved_at)=(.+)$` regex per line. Until that consumer ships, the format is also human-readable for ad-hoc verification.
|
|
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.
|
|
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}
|
|
@@ -397,13 +409,18 @@ To re-approve (e.g. after revising the plan):
|
|
|
397
409
|
|
|
398
410
|
A `--fresh` flag for `/clancy:approve-plan` is not implemented in this release. Manual deletion is the supported re-approval path.
|
|
399
411
|
|
|
400
|
-
### Marker is the gate for
|
|
412
|
+
### Marker is the gate for future implementation tooling
|
|
413
|
+
|
|
414
|
+
The `.approved` marker is designed as the gate that any plan-implementing tool checks before applying changes. The conceptual flow: read the marker, hash the current plan file, compare to the stored `sha256`. Match → proceed; mismatch → block with a "plan changed since approval" error. This is why the SHA must be computed over the plan file content (not just touched as an empty file).
|
|
401
415
|
|
|
402
|
-
|
|
416
|
+
A dedicated `/clancy:implement-from` slash command is **deferred** until `@chief-clancy/dev` is extracted (the slash command is convenience, not capability — Claude Code can already do the SHA gate + structured plan parse via natural-language instruction). In the meantime, users apply approved plans by:
|
|
417
|
+
|
|
418
|
+
1. Asking Claude Code to read the plan file directly: `Implement .clancy/plans/{stem}.md, verifying the .approved marker's sha256 first`
|
|
419
|
+
2. Installing the full Clancy pipeline (`npx chief-clancy`) for the board-driven flow
|
|
403
420
|
|
|
404
421
|
### After writing the marker
|
|
405
422
|
|
|
406
|
-
After the marker is written successfully, update the source brief file's marker comment (Step 4b below), then
|
|
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.
|
|
407
424
|
|
|
408
425
|
---
|
|
409
426
|
|
|
@@ -433,8 +450,8 @@ Open `.clancy/briefs/{brief-filename}` and find the marker comment matching this
|
|
|
433
450
|
|
|
434
451
|
This matches all of:
|
|
435
452
|
|
|
436
|
-
- `<!-- planned:1,2,3 -->` (no approved prefix
|
|
437
|
-
- `<!-- approved:1 planned:1,2,3 -->` (
|
|
453
|
+
- `<!-- planned:1,2,3 -->` (no approved prefix — earlier marker shape)
|
|
454
|
+
- `<!-- approved:1 planned:1,2,3 -->` (with the approved prefix)
|
|
438
455
|
- `<!-- approved: planned:1,2,3 -->` (empty approved list — should not happen but handle gracefully)
|
|
439
456
|
- `<!--planned:1,2,3-->` (no surrounding spaces — hand-edited)
|
|
440
457
|
|
|
@@ -463,7 +480,315 @@ The plan is still approved. The .clancy/plans/{stem}.approved marker is in place
|
|
|
463
480
|
You can manually update .clancy/briefs/{brief}.md if needed.
|
|
464
481
|
```
|
|
465
482
|
|
|
466
|
-
After Step 4b completes (successfully or with a warning),
|
|
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).
|
|
467
792
|
|
|
468
793
|
---
|
|
469
794
|
|
|
@@ -1164,7 +1489,9 @@ Clancy — Approve Plan (local)
|
|
|
1164
1489
|
Marker: .clancy/plans/{stem}.approved
|
|
1165
1490
|
sha256: {first 12 hex chars}…
|
|
1166
1491
|
|
|
1167
|
-
Next:
|
|
1492
|
+
Next:
|
|
1493
|
+
• Ask Claude Code: "Implement .clancy/plans/{stem}.md (verify the .approved marker's sha256 first)"
|
|
1494
|
+
• Or run `npx chief-clancy` for the full board-driven pipeline
|
|
1168
1495
|
|
|
1169
1496
|
"Book 'em, Lou."
|
|
1170
1497
|
```
|
|
@@ -1177,13 +1504,38 @@ Next: /clancy:implement-from .clancy/plans/{stem}.md
|
|
|
1177
1504
|
|
|
1178
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.
|
|
1179
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
|
+
|
|
1180
1515
|
Append to `.clancy/progress.txt`:
|
|
1181
1516
|
|
|
1182
1517
|
```
|
|
1183
1518
|
YYYY-MM-DD HH:MM | {stem} | LOCAL_APPROVE_PLAN | sha256={first 12 hex}
|
|
1184
1519
|
```
|
|
1185
1520
|
|
|
1186
|
-
The `LOCAL_APPROVE_PLAN` token mirrors the `LOCAL_PLAN` / `LOCAL_REVISED` convention used by `/clancy:plan --from` (see [`plan.md` Step 6](./plan.md)).
|
|
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.
|
|
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.
|
|
1187
1539
|
|
|
1188
1540
|
---
|
|
1189
1541
|
|
package/src/workflows/plan.md
CHANGED
|
@@ -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,14 +1105,14 @@ 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
|
|
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
|
|
1107
1112
|
4. If the marker's valid `sha256` matches the current plan file's hash → `Approved` (verdict)
|
|
1108
|
-
5. If the marker exists and its valid `sha256` differs from the current hash → `Stale (re-approve)` (verdict) — the plan file was edited after approval.
|
|
1113
|
+
5. If the marker exists and its valid `sha256` differs from the current hash → `Stale (re-approve)` (verdict) — the plan file was edited after approval. Any future plan-implementing tool will refuse to run against a stale plan until it is re-approved
|
|
1109
1114
|
|
|
1110
|
-
A future `Implemented` state — derived from `LOCAL_IMPLEMENT` entries in `.clancy/progress.txt` — will
|
|
1115
|
+
A future `Implemented` state — derived from `LOCAL_IMPLEMENT` entries in `.clancy/progress.txt` — will land alongside the dedicated plan-implementing tool (deferred to `@chief-clancy/dev`). Today the inventory shows three states (`Planned`, `Approved`, `Stale (re-approve)`), with malformed `.approved` markers folded into `Stale (re-approve)`; the table format is stable so the future addition will be a one-line extension.
|
|
1111
1116
|
|
|
1112
1117
|
A field is considered missing if the line is absent or its value is empty after the colon. Plans missing all expected fields are still listed (with `?` placeholders) so the user can find and clean them up.
|
|
1113
1118
|
|
|
@@ -645,9 +645,11 @@ describe('plan inventory step', () => {
|
|
|
645
645
|
expect(content).toContain('plan file was edited after approval');
|
|
646
646
|
});
|
|
647
647
|
|
|
648
|
-
it('reserves an Implemented state for
|
|
648
|
+
it('reserves an Implemented state for the deferred plan-implementing tool', () => {
|
|
649
649
|
expect(content).toContain('Implemented');
|
|
650
|
-
expect(content).
|
|
650
|
+
expect(content).toMatch(
|
|
651
|
+
/deferred[\s\S]{0,120}(?:plan-implementing tool|future implementation tooling)|(?:plan-implementing tool|future implementation tooling)[\s\S]{0,120}deferred/i,
|
|
652
|
+
);
|
|
651
653
|
expect(content).toContain('shows three states');
|
|
652
654
|
});
|
|
653
655
|
|
|
@@ -853,13 +855,24 @@ describe('approve-plan local marker (Step 4a)', () => {
|
|
|
853
855
|
expect(content).toContain('already approved');
|
|
854
856
|
});
|
|
855
857
|
|
|
856
|
-
it('explains the marker is the gate for
|
|
857
|
-
expect(content).toContain('/clancy:implement-from');
|
|
858
|
+
it('explains the marker is the gate for future implementation tooling', () => {
|
|
858
859
|
expect(content).toContain('gate');
|
|
860
|
+
expect(content).toContain('future implementation tooling');
|
|
861
|
+
expect(content).toMatch(
|
|
862
|
+
/deferred[\s\S]{0,120}(?:plan-implementing tool|future implementation tooling)|(?:plan-implementing tool|future implementation tooling)[\s\S]{0,120}deferred/i,
|
|
863
|
+
);
|
|
859
864
|
});
|
|
860
865
|
|
|
861
|
-
it('after writing the marker, Step 4a
|
|
862
|
-
|
|
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);
|
|
863
876
|
});
|
|
864
877
|
});
|
|
865
878
|
|
|
@@ -942,10 +955,10 @@ describe('approve-plan local-mode log + summary (Step 7)', () => {
|
|
|
942
955
|
expect(content).toContain('sha256={first 12 hex}');
|
|
943
956
|
});
|
|
944
957
|
|
|
945
|
-
it('local success summary points
|
|
946
|
-
expect(content).toContain(
|
|
947
|
-
|
|
948
|
-
);
|
|
958
|
+
it('local success summary points users at the deferred next-step paths', () => {
|
|
959
|
+
expect(content).toContain('Ask Claude Code:');
|
|
960
|
+
expect(content).toContain('Implement .clancy/plans/{stem}.md');
|
|
961
|
+
expect(content).toContain('npx chief-clancy');
|
|
949
962
|
});
|
|
950
963
|
|
|
951
964
|
it('preserves board-mode APPROVE_PLAN log entry', () => {
|
|
@@ -1001,3 +1014,651 @@ describe('approve-plan board mode preserved unchanged', () => {
|
|
|
1001
1014
|
expect(content).toContain('Notion');
|
|
1002
1015
|
});
|
|
1003
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
|
+
});
|