@chief-clancy/brief 0.1.2 → 0.3.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.
@@ -0,0 +1,1567 @@
1
+ # Clancy Approve Brief Workflow
2
+
3
+ ## Overview
4
+
5
+ Approve a reviewed strategic brief by creating child tickets on the board, linking dependencies, and posting a tracking summary on the parent ticket. Tickets are created sequentially in topological (dependency) order. Partial failures stop immediately — re-run to resume.
6
+
7
+ ---
8
+
9
+ ## Step 1 — Preflight checks
10
+
11
+ ### 1. Detect installation context
12
+
13
+ Check for `.clancy/.env`:
14
+
15
+ - **Absent** → **standalone mode**. The brief package has been installed via `npx @chief-clancy/brief` but board credentials have never been configured. There is nothing approve-brief can do without a board (its job is to create tickets ON the board), so stop with:
16
+
17
+ ```
18
+ No board credentials found. Run /clancy:board-setup first to configure your board, then re-run /clancy:approve-brief.
19
+ ```
20
+
21
+ Stop.
22
+
23
+ - **Present** → continue to `.clancy/clancy-implement.js` check below.
24
+
25
+ If `.clancy/.env` is present, check for `.clancy/clancy-implement.js`:
26
+
27
+ - **Present** → **terminal mode**. Full Clancy pipeline installed.
28
+ - **Absent** → **standalone+board mode**. Board credentials available via `/clancy:board-setup`. Brief and plan are installed as standalone packages but the full pipeline (`clancy-implement.js`) is not.
29
+
30
+ The detected install mode is captured for Step 6's pipeline label decision.
31
+
32
+ ### 2. Terminal-mode preflight (skip in standalone+board mode)
33
+
34
+ If in **terminal mode** (`.clancy/.env` present AND `.clancy/clancy-implement.js` present):
35
+
36
+ a. Source `.clancy/.env` and check board credentials are present.
37
+
38
+ b. Check `CLANCY_ROLES` includes `strategist` (or env var is unset, which indicates a global install where all roles are available). If `CLANCY_ROLES` is set but does not include `strategist`:
39
+
40
+ ```
41
+ The Strategist role is not enabled. Add "strategist" to CLANCY_ROLES in .clancy/.env or run /clancy:settings.
42
+ ```
43
+
44
+ Stop.
45
+
46
+ ### 3. Standalone+board preflight (only in standalone+board mode)
47
+
48
+ If in **standalone+board mode**, source `.clancy/.env` and check board credentials are present (same presence check as the terminal-mode preflight above — an empty or partial `.clancy/.env` should fail loudly here, not later in Step 6/7). Standalone+board users came in via `npx @chief-clancy/brief` and `/clancy:board-setup`, so they have no `CLANCY_ROLES` configured and the strategist role check above does not apply.
49
+
50
+ ### 4. Branch freshness check (all modes)
51
+
52
+ ```bash
53
+ git fetch origin
54
+ ```
55
+
56
+ Compare HEAD with `origin/$CLANCY_BASE_BRANCH`. If behind:
57
+
58
+ **AFK mode** (`--afk` flag or `CLANCY_MODE=afk`): auto-pull without prompting.
59
+
60
+ **Interactive mode:**
61
+
62
+ ```
63
+ Behind by N commits. [1] Pull latest [2] Continue [3] Abort
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Step 2 — Load brief
69
+
70
+ Scan `.clancy/briefs/` for unapproved files — `.md` files WITHOUT a corresponding `.md.approved` marker file.
71
+
72
+ ### Selection logic
73
+
74
+ Parse the argument (if any) against the unapproved list:
75
+
76
+ **No argument + 0 unapproved briefs:**
77
+
78
+ ```
79
+ No unapproved briefs found. Run /clancy:brief to generate one.
80
+ ```
81
+
82
+ Stop.
83
+
84
+ **No argument + 1 unapproved brief:**
85
+ Auto-select the single brief. Continue.
86
+
87
+ **No argument + 2+ unapproved briefs:**
88
+ Display numbered list:
89
+
90
+ ```
91
+ Multiple unapproved briefs found:
92
+
93
+ [1] {slug-a} ({date}) — {N} tickets — Source: {source}
94
+ [2] {slug-b} ({date}) — {N} tickets — Source: {source}
95
+
96
+ Which brief to approve? [1-N]
97
+ ```
98
+
99
+ **Argument is a positive integer (e.g. `2`):**
100
+ Select the Nth unapproved brief by index. If out of range: `Index out of range.` Stop.
101
+
102
+ **Argument matches a ticket identifier (`#\d+`, `[A-Z]+-\d+`, bare number for Azure DevOps, `[A-Za-z]{1,5}-\d+`/bare number for Shortcut, or UUID/`notion-XXXXXXXX` for Notion):**
103
+ Scan unapproved brief files for a `**Source:**` line containing the identifier. If 0 matches: show available briefs and stop. If 1 match: load it. If 2+ matches: show numbered list and ask.
104
+
105
+ **Argument is other text:**
106
+ Match by slug (filename contains the argument). If 0 matches: `No brief matching "{arg}" found.` Stop. If 1 match: load it. If 2+ matches: list and ask.
107
+
108
+ ### Already-approved guard
109
+
110
+ After loading, verify the `.md.approved` marker does not exist. If it does:
111
+
112
+ ```
113
+ Brief "{slug}" is already approved. No action needed.
114
+ ```
115
+
116
+ Stop.
117
+
118
+ ---
119
+
120
+ ## Step 3 — Parse decomposition table
121
+
122
+ Read the `## Ticket Decomposition` table from the brief file. Extract each row:
123
+
124
+ | Column | Field |
125
+ | -------------- | -------------------------------- |
126
+ | `#` | Sequence number |
127
+ | `Title` | Ticket title |
128
+ | `Description` | 1-2 sentence description |
129
+ | `Size` | S / M / L |
130
+ | `Dependencies` | References like `#1`, `#3` |
131
+ | `Mode` | AFK or HITL |
132
+ | `Ticket` | Board key (if partially created) |
133
+
134
+ ### Validation
135
+
136
+ - **0 tickets:** `No tickets found in the decomposition table. Add at least one ticket to the brief before approving.` Stop.
137
+ - **>10 tickets:** Warn: `Brief has {N} tickets (max recommended: 10). Large decompositions may indicate the idea should be split further.` Continue — advisory only.
138
+ - **Tickets already in Ticket column:** These were created in a prior partial run. Mark them for skipping during creation.
139
+ - **Circular dependencies:** Run cycle detection on the dependency graph. If a cycle exists: `Circular dependency detected between #{N} and #{M}. Edit the brief to resolve, then re-run.` Stop.
140
+
141
+ ### Topological sort
142
+
143
+ Order tickets by their dependency graph so blockers are created before dependents. This ensures blocking relationships can be linked immediately after creation.
144
+
145
+ ---
146
+
147
+ ## Step 4 — Confirm with user
148
+
149
+ Display the ticket list in dependency order:
150
+
151
+ ```
152
+ Clancy — Approve Brief
153
+
154
+ Brief: {slug}
155
+ Parent: {ticket key or "none (standalone)"}
156
+
157
+ Tickets to create (dependency order):
158
+ #1 [S] [AFK] Title — No deps
159
+ #2 [M] [HITL] Title — No deps
160
+ #3 [M] [AFK] Title — After #2
161
+ #4 [S] [AFK] Title — After #1, #3
162
+
163
+ Labels: {list of labels to apply}
164
+ Issue type: {type} (Jira only)
165
+ AFK-ready: {X} | Needs human: {Y}
166
+
167
+ Create {N} tickets? [Y/n]
168
+ ```
169
+
170
+ **AFK mode:** If running in AFK mode (`--afk` flag OR `CLANCY_MODE=afk`), skip the confirmation prompt and auto-confirm. Display the ticket list for logging purposes but proceed without waiting for input.
171
+
172
+ If user declines (interactive only): `Cancelled. No changes made.` Stop.
173
+
174
+ ---
175
+
176
+ ## Step 4a — Dry-run check
177
+
178
+ If `--dry-run` flag is set:
179
+
180
+ ```
181
+ Dry run complete. No tickets created.
182
+ ```
183
+
184
+ Stop. No API calls beyond preflight.
185
+
186
+ ---
187
+
188
+ ## Step 5 — Resolve parent
189
+
190
+ The parent ticket is where child tickets are linked. Determine the parent from one of these sources (in priority order):
191
+
192
+ ### Source 1 — Board-sourced brief
193
+
194
+ If the brief's `**Source:**` line contains a board ticket identifier (e.g. `[#50]`, `[PROJ-200]`, `[ENG-42]`), that ticket is the parent. Fetch it to validate:
195
+
196
+ #### GitHub
197
+
198
+ ```bash
199
+ curl -s \
200
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
201
+ -H "Accept: application/vnd.github+json" \
202
+ -H "X-GitHub-Api-Version: 2022-11-28" \
203
+ "https://api.github.com/repos/$GITHUB_REPO/issues/$PARENT_NUMBER"
204
+ ```
205
+
206
+ Check: `pull_request` present? -> `Parent #{N} is a PR, not an issue.` Stop. `state == "closed"`? -> Warn, ask `[y/N]`.
207
+
208
+ #### Jira
209
+
210
+ ```bash
211
+ curl -s \
212
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
213
+ -H "Accept: application/json" \
214
+ "$JIRA_BASE_URL/rest/api/3/issue/$PARENT_KEY?fields=summary,status,issuetype,priority"
215
+ ```
216
+
217
+ Check: `status.statusCategory.key == "done"`? -> Warn, ask `[y/N]`. Note if `issuetype.name == "Epic"` (ideal case for parent linking).
218
+
219
+ #### Linear
220
+
221
+ ```graphql
222
+ query {
223
+ issues(filter: { identifier: { eq: "$PARENT_KEY" } }) {
224
+ nodes {
225
+ id
226
+ identifier
227
+ title
228
+ state {
229
+ type
230
+ }
231
+ team {
232
+ id
233
+ key
234
+ }
235
+ children {
236
+ nodes {
237
+ id
238
+ identifier
239
+ title
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+ ```
246
+
247
+ Check: `state.type == "completed"` or `"canceled"`? -> Warn, ask `[y/N]`. `team.id != LINEAR_TEAM_ID`? -> Warn about cross-team children, ask `[Y/n]`.
248
+
249
+ #### Azure DevOps
250
+
251
+ ```bash
252
+ curl -s \
253
+ -u ":$AZDO_PAT" \
254
+ -H "Accept: application/json" \
255
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID?\$expand=relations&api-version=7.1"
256
+ ```
257
+
258
+ Check: `fields["System.State"]` is `Done`/`Closed`/`Resolved`? -> Warn, ask `[y/N]`. Check `relations` for existing children (type `System.LinkTypes.Hierarchy-Forward`).
259
+
260
+ #### Shortcut
261
+
262
+ If the parent is a Shortcut epic, fetch it:
263
+
264
+ ```bash
265
+ curl -s \
266
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
267
+ "https://api.app.shortcut.com/api/v3/epics/$EPIC_ID"
268
+ ```
269
+
270
+ Check: `completed` or `archived`? -> Warn, ask `[y/N]`. Fetch epic stories via `GET /api/v3/epics/$EPIC_ID/stories` to check for existing children.
271
+
272
+ If the parent is a story (not an epic), fetch it:
273
+
274
+ ```bash
275
+ curl -s \
276
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
277
+ "https://api.app.shortcut.com/api/v3/stories/$STORY_ID"
278
+ ```
279
+
280
+ Check: `completed` or `archived`? -> Warn, ask `[y/N]`. Check `story_links` for existing children.
281
+
282
+ #### Notion
283
+
284
+ ```bash
285
+ curl -s \
286
+ -H "Authorization: Bearer $NOTION_TOKEN" \
287
+ -H "Notion-Version: 2022-06-28" \
288
+ "https://api.notion.com/v1/pages/$PAGE_ID"
289
+ ```
290
+
291
+ Check: `archived` is `true`? -> Warn, ask `[y/N]`. Check `relation` properties for existing child pages.
292
+
293
+ **Notion limitation:** Notion does not have a native parent-child hierarchy like other boards. Parent relationships are modelled via `relation` properties in the database. If no relation property is configured, children are standalone pages in the same database.
294
+
295
+ ### Source 2 — `--epic` flag
296
+
297
+ If `--epic` is provided (e.g. `--epic PROJ-200`, `--epic #100`, `--epic ENG-42`, `--epic SC-123`, `--epic notion-XXXXXXXX`), validate the target using the same checks as Source 1. If not found or is Done: stop.
298
+
299
+ Note: `--epic` is ignored for board-sourced briefs (the source ticket IS the parent).
300
+
301
+ ### Source 3 — `CLANCY_BRIEF_EPIC` default
302
+
303
+ If no board source and no `--epic` flag, check `.clancy/.env` for `CLANCY_BRIEF_EPIC`. If set, validate and use it as the parent.
304
+
305
+ ### Source 4 — No parent (standalone)
306
+
307
+ If none of the above apply:
308
+
309
+ ```
310
+ No parent specified. Tickets will be created as standalone.
311
+ Use --epic {KEY} to attach to a parent.
312
+ ```
313
+
314
+ Continue — omit parent fields from creation payloads. No tracking comment will be posted.
315
+
316
+ ---
317
+
318
+ ## Step 6 — Look up queue state / labels
319
+
320
+ Platform-specific pre-creation lookups.
321
+
322
+ ### Pipeline label selection rule (applies to all six platforms below)
323
+
324
+ Every child ticket gets exactly one pipeline label. The label determines which queue picks the ticket up downstream (`/clancy:plan` for the planning queue, `/clancy:implement` for the build queue). The selection rule, in order of precedence:
325
+
326
+ 1. `--skip-plan` flag is set → use `CLANCY_LABEL_BUILD` from `.clancy/.env` if set, otherwise `clancy:build`. The user has explicitly opted out of the planning queue for this run.
327
+ 2. Install mode is **standalone+board** (Step 1 detected this) → use `CLANCY_LABEL_PLAN` from `.clancy/.env` if set, otherwise `clancy:plan`. Standalone+board users have installed both `@chief-clancy/brief` and `@chief-clancy/plan` as standalone packages and clearly intend to use plan, even though they have no `CLANCY_ROLES` configured.
328
+ 3. Install mode is **terminal** AND `CLANCY_ROLES` includes `planner` (or `CLANCY_ROLES` is unset, indicating a global install where all roles are available) → use `CLANCY_LABEL_PLAN` from `.clancy/.env` if set, otherwise `clancy:plan`.
329
+ 4. Install mode is **terminal** AND `CLANCY_ROLES` is set but does NOT include `planner` → use `CLANCY_LABEL_BUILD` from `.clancy/.env` if set, otherwise `clancy:build`. The terminal user has explicitly opted out of the planning queue by not enabling the planner role.
330
+
331
+ This rule replaces the per-platform fallthrough that used to live in each of the six platform subsections below. Each subsection now applies the rule rather than re-enumerating it. Apply the chosen label to each child ticket, creating it first only on platforms that require explicit label creation (the per-platform subsections below document which platforms auto-create vs require pre-creation).
332
+
333
+ ### GitHub
334
+
335
+ **Pipeline label for children — mandatory.** Apply the pipeline label per the rule above to every child ticket.
336
+
337
+ **Labels to apply per ticket:**
338
+
339
+ - The pipeline label determined above (`CLANCY_LABEL_PLAN` or `CLANCY_LABEL_BUILD`) — replaces `CLANCY_LABEL` on children
340
+ - `component:{CLANCY_COMPONENT}` (if `CLANCY_COMPONENT` set)
341
+ - `size:{S|M|L}` — from decomposition table
342
+ - `clancy:afk` or `clancy:hitl` — from Mode column
343
+
344
+ Note: `CLANCY_LABEL` is NOT applied to child tickets when pipeline labels are active. The pipeline label (`clancy:plan` or `clancy:build`) serves as the queue identifier.
345
+
346
+ **Label pre-creation:** For each unique label, attempt to create it on the repo. If it already exists, GitHub returns 422 — ignore that error. If 403 (no admin access), note the label as unavailable.
347
+
348
+ ```bash
349
+ curl -s \
350
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
351
+ -H "Accept: application/vnd.github+json" \
352
+ -H "X-GitHub-Api-Version: 2022-11-28" \
353
+ -H "Content-Type: application/json" \
354
+ -X POST \
355
+ "https://api.github.com/repos/$GITHUB_REPO/labels" \
356
+ -d '{"name": "$LABEL_NAME", "color": "d4c5f9"}'
357
+ ```
358
+
359
+ If label creation fails with 403: warn, continue without that label.
360
+
361
+ **Resolve username** for assignee:
362
+
363
+ ```bash
364
+ curl -s \
365
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
366
+ -H "Accept: application/vnd.github+json" \
367
+ "https://api.github.com/user"
368
+ ```
369
+
370
+ Cache the `login` field for all ticket creations.
371
+
372
+ ### Jira
373
+
374
+ **Validate issue type exists in project:**
375
+
376
+ ```bash
377
+ curl -s \
378
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
379
+ -H "Accept: application/json" \
380
+ "$JIRA_BASE_URL/rest/api/3/issue/createmeta/$JIRA_PROJECT_KEY/issuetypes"
381
+ ```
382
+
383
+ Look for `CLANCY_BRIEF_ISSUE_TYPE` (default: `Task`). If not found:
384
+
385
+ ```
386
+ Issue type "{type}" not available in project {PROJ}. Available types: {list}.
387
+ Set CLANCY_BRIEF_ISSUE_TYPE in .clancy/.env.
388
+ ```
389
+
390
+ Stop.
391
+
392
+ **Pipeline label for children — mandatory.** Apply the pipeline label per the rule at the top of Step 6. Defaults: `clancy:plan` / `clancy:build`. Always apply to every child ticket.
393
+
394
+ **Labels:** Jira auto-creates labels — no pre-creation needed. Apply: the pipeline label determined above, `clancy:afk` or `clancy:hitl`. `CLANCY_LABEL` is NOT applied to children when pipeline labels are active.
395
+
396
+ **Components:** If `CLANCY_COMPONENT` is set, it maps to the Jira `components` field.
397
+
398
+ **Priority:** Inherit from parent ticket if available. Otherwise omit (Jira uses project default).
399
+
400
+ ### Linear
401
+
402
+ **Look up backlog state UUID:**
403
+
404
+ ```graphql
405
+ query {
406
+ workflowStates(
407
+ filter: { team: { id: { eq: "$LINEAR_TEAM_ID" } }, type: { eq: "backlog" } }
408
+ ) {
409
+ nodes {
410
+ id
411
+ name
412
+ }
413
+ }
414
+ }
415
+ ```
416
+
417
+ Use `nodes[0].id`. If empty, fall back to `triage` type, then `unstarted`, then any first state. If truly nothing found, warn and use the team default.
418
+
419
+ **Look up label UUIDs:**
420
+
421
+ ```graphql
422
+ query {
423
+ team(id: "$LINEAR_TEAM_ID") {
424
+ labels {
425
+ nodes {
426
+ id
427
+ name
428
+ }
429
+ }
430
+ }
431
+ }
432
+ ```
433
+
434
+ **Pipeline label for children — mandatory.** Apply the pipeline label per the rule at the top of Step 6. Defaults: `clancy:plan` / `clancy:build`. Always apply to every child ticket.
435
+
436
+ For each required label (the pipeline label, `component:{CLANCY_COMPONENT}`, `clancy:afk`, `clancy:hitl`): search by exact name. `CLANCY_LABEL` is NOT applied to children when pipeline labels are active. If not found in team labels, check workspace labels:
437
+
438
+ ```graphql
439
+ query {
440
+ issueLabels(filter: { name: { eq: "$LABEL_NAME" } }) {
441
+ nodes {
442
+ id
443
+ name
444
+ }
445
+ }
446
+ }
447
+ ```
448
+
449
+ If still not found, auto-create at team level:
450
+
451
+ ```graphql
452
+ mutation {
453
+ issueLabelCreate(input: { teamId: "$LINEAR_TEAM_ID", name: "$LABEL_NAME" }) {
454
+ success
455
+ issueLabel {
456
+ id
457
+ name
458
+ }
459
+ }
460
+ }
461
+ ```
462
+
463
+ On failure to create label: warn, continue without it.
464
+
465
+ ### Azure DevOps
466
+
467
+ **Work item type:** Azure DevOps uses configurable work item types. Use `CLANCY_BRIEF_ISSUE_TYPE` (default: `Task`). Validate by fetching available types:
468
+
469
+ ```bash
470
+ curl -s \
471
+ -u ":$AZDO_PAT" \
472
+ -H "Accept: application/json" \
473
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitemtypes?api-version=7.1"
474
+ ```
475
+
476
+ If the specified type is not found, stop with the available types listed.
477
+
478
+ **Pipeline tag for children — mandatory.** Apply the pipeline tag determined by the rule at the top of Step 6. Defaults: `clancy:plan` / `clancy:build`. Tags are applied via the `System.Tags` field (semicolon-delimited).
479
+
480
+ **Tags:** Azure DevOps auto-creates tags — no pre-creation needed. Apply: the pipeline tag, `clancy:afk` or `clancy:hitl`. `CLANCY_LABEL` is NOT applied to children when pipeline tags are active.
481
+
482
+ **Resolve authenticated user** for assignment:
483
+
484
+ ```bash
485
+ curl -s \
486
+ -u ":$AZDO_PAT" \
487
+ -H "Accept: application/json" \
488
+ "https://dev.azure.com/$AZDO_ORG/_apis/connectionData"
489
+ ```
490
+
491
+ Use `authenticatedUser.providerDisplayName` for the `System.AssignedTo` field.
492
+
493
+ ### Shortcut
494
+
495
+ **Story type:** Shortcut creates stories by default. Use `story_type` field if needed (`feature`, `bug`, `chore` — default: `feature`).
496
+
497
+ **Pipeline label for children — mandatory.** Apply the pipeline label per the rule at the top of Step 6. Defaults: `clancy:plan` / `clancy:build`.
498
+
499
+ **Labels:** Resolve label IDs via `GET /api/v3/labels`. If the required label does not exist, create it:
500
+
501
+ ```bash
502
+ curl -s \
503
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
504
+ -H "Content-Type: application/json" \
505
+ -X POST \
506
+ "https://api.app.shortcut.com/api/v3/labels" \
507
+ -d '{"name": "$LABEL_NAME", "color": "#d4c5f9"}'
508
+ ```
509
+
510
+ On failure to create label: warn, continue without it. Apply: the pipeline label, `clancy:afk` or `clancy:hitl`. `CLANCY_LABEL` is NOT applied to children when pipeline labels are active.
511
+
512
+ **Resolve current member** for assignment:
513
+
514
+ ```bash
515
+ curl -s \
516
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
517
+ "https://api.app.shortcut.com/api/v3/member-info"
518
+ ```
519
+
520
+ Use `id` as the `owner_ids` value for story creation.
521
+
522
+ ### Notion
523
+
524
+ **Pipeline label for children — mandatory.** Apply the pipeline label per the rule at the top of Step 6. Defaults: `clancy:plan` / `clancy:build`. Labels are applied via multi-select properties.
525
+
526
+ **Labels:** Notion auto-creates multi-select options when first used — no pre-creation needed. Apply: the pipeline label, `clancy:afk` or `clancy:hitl`. `CLANCY_LABEL` is NOT applied to children when pipeline labels are active.
527
+
528
+ Use `CLANCY_NOTION_LABELS` to specify which multi-select property holds labels (configurable per database).
529
+
530
+ **Assignee:** Use `CLANCY_NOTION_ASSIGNEE` property name. Resolve the current user via the Notion API:
531
+
532
+ ```bash
533
+ curl -s \
534
+ -H "Authorization: Bearer $NOTION_TOKEN" \
535
+ -H "Notion-Version: 2022-06-28" \
536
+ "https://api.notion.com/v1/users/me"
537
+ ```
538
+
539
+ Use `id` for the people property value.
540
+
541
+ **Status:** Resolve the backlog status value from `CLANCY_PLAN_STATUS` (default: `Backlog`). Use `CLANCY_NOTION_STATUS` for the status property name.
542
+
543
+ ---
544
+
545
+ ## Step 6a — Pre-creation race check
546
+
547
+ Create the `.approved` marker file with exclusive create (O_EXCL) to prevent concurrent approval:
548
+
549
+ ```
550
+ Marker path: .clancy/briefs/{filename}.approved
551
+ ```
552
+
553
+ If the file already exists (`EEXIST`):
554
+
555
+ ```
556
+ Brief is being approved by another process.
557
+ ```
558
+
559
+ Stop.
560
+
561
+ If creation succeeds: the marker acts as a lock. **If ticket creation fails later, delete this marker** (partial failure = not approved).
562
+
563
+ ---
564
+
565
+ ## Step 7 — Create child tickets
566
+
567
+ Create tickets **sequentially** in topological (dependency) order. Wait **500ms** between each creation to respect rate limits.
568
+
569
+ For each ticket in dependency order:
570
+
571
+ 1. **Check if already created:** If the Ticket column has a value from a prior partial run, skip this ticket. Display: `[{i}/{N}] skip {KEY} — already exists`
572
+
573
+ 2. **Build the creation payload** (platform-specific — see below).
574
+
575
+ 3. **Send the API request.**
576
+
577
+ 4. **On success:** Record the created key/number. Map the decomposition `#N` to the board key for dependency linking. Display: `[{i}/{N}] + {KEY} — {Title} [{Mode}]`
578
+
579
+ 5. **On failure:** STOP immediately. Do NOT attempt remaining tickets. See partial failure handling in Step 10.
580
+
581
+ 6. **Wait 500ms** before the next creation.
582
+
583
+ ### GitHub — POST /repos/{owner}/{repo}/issues
584
+
585
+ ```bash
586
+ curl -s \
587
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
588
+ -H "Accept: application/vnd.github+json" \
589
+ -H "X-GitHub-Api-Version: 2022-11-28" \
590
+ -H "Content-Type: application/json" \
591
+ -X POST \
592
+ "https://api.github.com/repos/$GITHUB_REPO/issues" \
593
+ -d '{
594
+ "title": "{ticket title}",
595
+ "body": "Epic: #{parent_number}\n\n## {Title}\n\n{Description}\n\n---\n\n**Parent:** #{parent_number}\n**Brief:** {slug}\n**Size:** {S|M|L}\n\n### Dependencies\n\n{Depends on #NN lines or None}\n\n---\n\n*Created by Clancy from strategic brief.*",
596
+ "labels": ["{PIPELINE_LABEL}", "size:{size}", "clancy:{mode}", ...],
597
+ "assignees": ["{resolved_username}"]
598
+ }'
599
+ ```
600
+
601
+ The `Epic: #{parent_number}` line is always the FIRST line of the body — this enables cross-platform epic completion detection.
602
+
603
+ Dependencies in the body use resolved issue numbers: `Depends on #51` (not decomposition indices).
604
+
605
+ If no parent: omit `Epic:` and `Parent:` lines, omit `assignees` if not resolvable.
606
+
607
+ **On 422 (label validation error):** Retry without the invalid label(s). Warn: `Label "{name}" does not exist. Created issue without it.`
608
+
609
+ **On 429 (rate limited):** Check `X-RateLimit-Reset` header. Wait until reset, retry once. If still limited: stop, enter partial failure flow.
610
+
611
+ ### Jira — POST /rest/api/3/issue
612
+
613
+ ```bash
614
+ curl -s \
615
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
616
+ -H "Content-Type: application/json" \
617
+ -H "Accept: application/json" \
618
+ -X POST \
619
+ "$JIRA_BASE_URL/rest/api/3/issue" \
620
+ -d '{
621
+ "fields": {
622
+ "project": { "key": "$JIRA_PROJECT_KEY" },
623
+ "summary": "{ticket title}",
624
+ "description": {
625
+ "version": 1,
626
+ "type": "doc",
627
+ "content": [
628
+ {
629
+ "type": "paragraph",
630
+ "content": [
631
+ { "type": "text", "text": "Epic: {PARENT_KEY}\n\n{Description}" }
632
+ ]
633
+ }
634
+ ]
635
+ },
636
+ "issuetype": { "name": "{CLANCY_BRIEF_ISSUE_TYPE or Task}" },
637
+ "parent": { "key": "{PARENT_KEY}" },
638
+ "labels": ["{PIPELINE_LABEL}", "clancy:{mode}"]
639
+ }
640
+ }'
641
+ ```
642
+
643
+ **Conditional fields (include only when set):**
644
+
645
+ - `CLANCY_COMPONENT` -> `"components": [{ "name": "{value}" }]`
646
+ - Parent has priority -> `"priority": { "name": "{priority}" }`
647
+
648
+ **Parent field fallback (classic projects):**
649
+ If the API returns 400 with an error mentioning the `parent` field, retry with `customfield_10014` instead:
650
+
651
+ ```json
652
+ "customfield_10014": "{PARENT_KEY}"
653
+ ```
654
+
655
+ Cache which field works for the remaining tickets in this batch.
656
+
657
+ If both `parent` and `customfield_10014` fail:
658
+
659
+ ```
660
+ Could not set parent. Continue creating tickets without parent? [y/N]
661
+ ```
662
+
663
+ Default: N (stop). If Y: create remaining tickets without parent field.
664
+
665
+ **On 400 (component not found):** Retry without `components` field. Warn.
666
+
667
+ **On 429 (rate limited):** Honour `Retry-After` header. Wait, retry once. If still 429: stop, enter partial failure flow.
668
+
669
+ ### Linear — issueCreate mutation
670
+
671
+ ```graphql
672
+ mutation {
673
+ issueCreate(
674
+ input: {
675
+ teamId: "$LINEAR_TEAM_ID"
676
+ title: "{ticket title}"
677
+ description: "Epic: {PARENT_IDENTIFIER}\n\n{Description}"
678
+ parentId: "{PARENT_UUID}"
679
+ stateId: "{BACKLOG_STATE_UUID}"
680
+ labelIds: ["{label UUIDs}"]
681
+ priority: 0
682
+ }
683
+ ) {
684
+ success
685
+ issue {
686
+ id
687
+ identifier
688
+ title
689
+ url
690
+ }
691
+ }
692
+ }
693
+ ```
694
+
695
+ If no parent: omit `parentId`.
696
+
697
+ **On rate limit (RATELIMITED error code):** Wait 60s, retry once. If still limited: stop, enter partial failure flow.
698
+
699
+ ### Azure DevOps — POST work item
700
+
701
+ ```bash
702
+ curl -s \
703
+ -u ":$AZDO_PAT" \
704
+ -X POST \
705
+ -H "Content-Type: application/json-patch+json" \
706
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/\$${WORK_ITEM_TYPE}?api-version=7.1" \
707
+ -d '[
708
+ {"op": "add", "path": "/fields/System.Title", "value": "{ticket title}"},
709
+ {"op": "add", "path": "/fields/System.Description", "value": "<h3>{Title}</h3><p>{Description as HTML}</p><hr><p><strong>Brief:</strong> {slug}</p><p><strong>Size:</strong> {S|M|L}</p>"},
710
+ {"op": "add", "path": "/fields/System.Tags", "value": "{PIPELINE_TAG}; clancy:{mode}"},
711
+ {"op": "add", "path": "/fields/System.AssignedTo", "value": "{resolved_user}"},
712
+ {"op": "add", "path": "/relations/-", "value": {"rel": "System.LinkTypes.Hierarchy-Reverse", "url": "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID"}}
713
+ ]'
714
+ ```
715
+
716
+ **Work item type:** Use `CLANCY_BRIEF_ISSUE_TYPE` (default: `Task`). The type is part of the URL path: `$${type}`.
717
+
718
+ **Parent linking:** Azure DevOps uses the `relations` array with `System.LinkTypes.Hierarchy-Reverse` to link children to parents. This is included in the creation payload as a relation add operation.
719
+
720
+ **Description:** Azure DevOps uses HTML. Convert the ticket description markdown to HTML.
721
+
722
+ If no parent: omit the `relations/-` operation from the JSON Patch array.
723
+
724
+ **On 400 (validation error):** Check if the work item type or a field is invalid. Warn with the specific error and stop.
725
+
726
+ **On 429 (rate limited):** Check `Retry-After` header. Wait, retry once. If still 429: stop, enter partial failure flow.
727
+
728
+ ### Shortcut — POST story
729
+
730
+ ```bash
731
+ curl -s \
732
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
733
+ -H "Content-Type: application/json" \
734
+ -X POST \
735
+ "https://api.app.shortcut.com/api/v3/stories" \
736
+ -d '{
737
+ "name": "{ticket title}",
738
+ "description": "Epic: {PARENT_KEY}\n\n{Description}\n\n---\n\n**Brief:** {slug}\n**Size:** {S|M|L}",
739
+ "story_type": "feature",
740
+ "labels": [{"name": "{PIPELINE_LABEL}"}, {"name": "clancy:{mode}"}],
741
+ "owner_ids": ["{resolved_member_id}"],
742
+ "epic_id": {EPIC_ID_or_null},
743
+ "workflow_state_id": {BACKLOG_STATE_ID}
744
+ }'
745
+ ```
746
+
747
+ **Parent linking:** If the parent is a Shortcut epic, use the `epic_id` field. If the parent is a story, omit `epic_id` and link via `story_links` after creation (see Step 8).
748
+
749
+ **Description:** Shortcut uses markdown. The `Epic: {PARENT_KEY}` line is always the first line of the description.
750
+
751
+ **Workflow state:** Resolve the backlog state ID from the default workflow:
752
+
753
+ ```bash
754
+ WORKFLOWS=$(curl -s \
755
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
756
+ "https://api.app.shortcut.com/api/v3/workflows")
757
+ ```
758
+
759
+ Find the state with `type: "backlog"` (or `"unstarted"` as fallback). Use its `id` for `workflow_state_id`.
760
+
761
+ If no parent: omit `epic_id` (or set to `null`).
762
+
763
+ **On 429 (rate limited):** Check `Retry-After` header. Wait, retry once. If still 429: stop, enter partial failure flow.
764
+
765
+ ### Notion — POST page
766
+
767
+ ```bash
768
+ curl -s \
769
+ -H "Authorization: Bearer $NOTION_TOKEN" \
770
+ -H "Notion-Version: 2022-06-28" \
771
+ -X POST \
772
+ "https://api.notion.com/v1/pages" \
773
+ -d '{
774
+ "parent": {"database_id": "$NOTION_DATABASE_ID"},
775
+ "properties": {
776
+ "Name": {"title": [{"text": {"content": "{ticket title}"}}]},
777
+ "$CLANCY_NOTION_STATUS": {"status": {"name": "$CLANCY_PLAN_STATUS"}},
778
+ "$CLANCY_NOTION_LABELS": {"multi_select": [{"name": "{PIPELINE_LABEL}"}, {"name": "clancy:{mode}"}]},
779
+ "$CLANCY_NOTION_ASSIGNEE": {"people": [{"id": "{resolved_user_id}"}]}
780
+ }
781
+ }'
782
+ ```
783
+
784
+ After creation, append the description as page content blocks:
785
+
786
+ ```bash
787
+ curl -s \
788
+ -H "Authorization: Bearer $NOTION_TOKEN" \
789
+ -H "Notion-Version: 2022-06-28" \
790
+ -X PATCH \
791
+ "https://api.notion.com/v1/blocks/$NEW_PAGE_ID/children" \
792
+ -d '{"children": [
793
+ {"type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": "Epic: {PARENT_KEY}"}}]}},
794
+ {"type": "divider", "divider": {}},
795
+ {"type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": "{Description}"}}]}},
796
+ {"type": "divider", "divider": {}},
797
+ {"type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": "Brief: {slug}\nSize: {S|M|L}"}}]}}
798
+ ]}'
799
+ ```
800
+
801
+ **Parent linking:** If the parent is a Notion page with a `relation` property configured, update the relation on the child page to point to the parent. If no relation property: children are standalone pages in the same database.
802
+
803
+ **Notion limitation:** Page content is stored as blocks, not a description field. The `Epic: {PARENT_KEY}` line is the first block of every child page. Each `rich_text` block has a 2000-character limit — split longer descriptions across multiple blocks.
804
+
805
+ If no parent: omit the `Epic:` paragraph block and relation property.
806
+
807
+ **On 429 (rate limited):** Check `Retry-After` header. Wait, retry once. If still 429: stop, enter partial failure flow.
808
+
809
+ ---
810
+
811
+ ## Step 8 — Link dependencies
812
+
813
+ For each ticket with dependencies, create blocking relationships on the board. This is **best-effort** — warn on failure, do not stop.
814
+
815
+ Skip links involving uncreated tickets (from partial failures).
816
+
817
+ ### GitHub
818
+
819
+ No separate API call needed. Dependencies are embedded in the issue body as `Depends on #{number}` text. GitHub auto-creates cross-references from the `#N` mentions.
820
+
821
+ ### Jira — POST /rest/api/3/issueLink
822
+
823
+ For each dependency (e.g. ticket #3 depends on #2):
824
+
825
+ ```bash
826
+ curl -s \
827
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
828
+ -H "Content-Type: application/json" \
829
+ -H "Accept: application/json" \
830
+ -X POST \
831
+ "$JIRA_BASE_URL/rest/api/3/issueLink" \
832
+ -d '{
833
+ "type": { "name": "Blocks" },
834
+ "inwardIssue": { "key": "{BLOCKER_KEY}" },
835
+ "outwardIssue": { "key": "{DEPENDENT_KEY}" }
836
+ }'
837
+ ```
838
+
839
+ `inwardIssue` = the blocker, `outwardIssue` = the dependent ticket.
840
+
841
+ Wait **200ms** between each link creation.
842
+
843
+ On failure: `Could not link {DEPENDENT} -> {BLOCKER} (dependency). Link manually if needed.`
844
+
845
+ ### Linear — issueRelationCreate mutation
846
+
847
+ ```graphql
848
+ mutation {
849
+ issueRelationCreate(
850
+ input: {
851
+ issueId: "{DEPENDENT_UUID}"
852
+ relatedIssueId: "{BLOCKER_UUID}"
853
+ type: blockedBy
854
+ }
855
+ ) {
856
+ success
857
+ }
858
+ }
859
+ ```
860
+
861
+ `issueId` = the dependent ticket, `relatedIssueId` = the blocker.
862
+
863
+ Wait **200ms** between each relation creation.
864
+
865
+ On failure: `Could not link {DEPENDENT} -> {BLOCKER}. Add manually in Linear.`
866
+
867
+ ### Azure DevOps — PATCH work item (add relation)
868
+
869
+ For each dependency, add a `System.LinkTypes.Dependency-Reverse` relation (predecessor link):
870
+
871
+ ```bash
872
+ curl -s \
873
+ -u ":$AZDO_PAT" \
874
+ -X PATCH \
875
+ -H "Content-Type: application/json-patch+json" \
876
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$DEPENDENT_ID?api-version=7.1" \
877
+ -d '[{"op": "add", "path": "/relations/-", "value": {"rel": "System.LinkTypes.Dependency-Reverse", "url": "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$BLOCKER_ID", "attributes": {"comment": "Depends on #$BLOCKER_ID (from Clancy brief)"}}}]'
878
+ ```
879
+
880
+ `System.LinkTypes.Dependency-Reverse` = "Predecessor" (blocker must finish before dependent can start).
881
+
882
+ Wait **200ms** between each link creation.
883
+
884
+ On failure: `Could not link {DEPENDENT} -> {BLOCKER}. Link manually in Azure DevOps.`
885
+
886
+ ### Shortcut — POST story link
887
+
888
+ For each dependency (e.g. ticket #3 depends on #2), create a story link:
889
+
890
+ ```bash
891
+ curl -s \
892
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
893
+ -H "Content-Type: application/json" \
894
+ -X POST \
895
+ "https://api.app.shortcut.com/api/v3/story-links" \
896
+ -d '{
897
+ "subject_id": {BLOCKER_STORY_ID},
898
+ "object_id": {DEPENDENT_STORY_ID},
899
+ "verb": "blocks"
900
+ }'
901
+ ```
902
+
903
+ `subject_id` = the blocker story, `object_id` = the dependent story. The `verb: "blocks"` means "subject blocks object".
904
+
905
+ Wait **200ms** between each link creation.
906
+
907
+ On failure: `Could not link {DEPENDENT} -> {BLOCKER}. Link manually in Shortcut.`
908
+
909
+ ### Notion — relation properties
910
+
911
+ Notion uses **relation properties** for dependencies between pages. If the database has a relation property configured for dependencies:
912
+
913
+ ```bash
914
+ curl -s \
915
+ -H "Authorization: Bearer $NOTION_TOKEN" \
916
+ -H "Notion-Version: 2022-06-28" \
917
+ -X PATCH \
918
+ "https://api.notion.com/v1/pages/$DEPENDENT_PAGE_ID" \
919
+ -d '{"properties": {"$DEPENDENCY_RELATION_PROPERTY": {"relation": [{"id": "$BLOCKER_PAGE_ID"}, ...existing_relations]}}}'
920
+ ```
921
+
922
+ **Notion limitation:** If no relation property is configured for dependencies, dependency linking is not possible via the API. In this case, add a `Depends on: notion-XXXXXXXX` line to the page content blocks instead (best-effort text reference).
923
+
924
+ Wait **200ms** between each relation update.
925
+
926
+ On failure: `Could not link {DEPENDENT} -> {BLOCKER}. Link manually in Notion.`
927
+
928
+ ---
929
+
930
+ ## Step 9 — Update brief file
931
+
932
+ After creating tickets, update the local brief file:
933
+
934
+ 1. **Add ticket keys** to the `Ticket` column in the decomposition table:
935
+
936
+ ```markdown
937
+ | # | Title | Description | Size | Dependencies | Mode | Ticket |
938
+ | --- | ---------------------- | ----------- | ---- | ------------ | ---- | -------- |
939
+ | 1 | Set up route structure | ... | S | None | AFK | PROJ-201 |
940
+ | 2 | SSO login flow | ... | M | None | HITL | PROJ-202 |
941
+ ```
942
+
943
+ 2. **Update status** from `Draft` to `Approved` (in the `**Status:**` line if present).
944
+
945
+ ---
946
+
947
+ ## Step 10 — Mark approved
948
+
949
+ Check whether all tickets were created successfully:
950
+
951
+ **All created:**
952
+
953
+ - The `.approved` marker file (created in Step 6a) stays in place.
954
+
955
+ **Partial failure (some tickets failed):**
956
+
957
+ - **DELETE** the `.approved` marker file. Partial failure = not approved.
958
+ - Update the brief file with the tickets that WERE created (Step 9 still applies).
959
+ - Display partial failure summary:
960
+
961
+ ```
962
+ Partial: {M} of {N} tickets created
963
+
964
+ PROJ-201 [S] Set up route structure
965
+ PROJ-202 [M] SSO login flow
966
+ X #3 Role-based access control — FAILED ({error})
967
+ - #4-#{N} not attempted
968
+
969
+ Created tickets are live on the board. To complete:
970
+ 1. Fix the issue (check board status/permissions)
971
+ 2. Re-run /clancy:approve-brief to resume creating remaining tickets
972
+ ```
973
+
974
+ - Log: `YYYY-MM-DD HH:MM | APPROVE_BRIEF | {slug} | PARTIAL — {M} of {N} created`
975
+ - Stop (do not post tracking comment for partial failures).
976
+
977
+ ---
978
+
979
+ ## Step 11 — Post tracking summary on parent
980
+
981
+ Only if a parent ticket exists AND all tickets were created successfully.
982
+
983
+ Post a comment on the parent ticket listing all created children.
984
+
985
+ ### GitHub
986
+
987
+ ```bash
988
+ curl -s \
989
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
990
+ -H "Accept: application/vnd.github+json" \
991
+ -H "X-GitHub-Api-Version: 2022-11-28" \
992
+ -H "Content-Type: application/json" \
993
+ -X POST \
994
+ "https://api.github.com/repos/$GITHUB_REPO/issues/$PARENT_NUMBER/comments" \
995
+ -d '{"body": "<tracking comment markdown>"}'
996
+ ```
997
+
998
+ ### Jira
999
+
1000
+ ```bash
1001
+ curl -s \
1002
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
1003
+ -H "Content-Type: application/json" \
1004
+ -H "Accept: application/json" \
1005
+ -X POST \
1006
+ "$JIRA_BASE_URL/rest/api/3/issue/$PARENT_KEY/comment" \
1007
+ -d '{"body": { "version": 1, "type": "doc", "content": [/* ADF table */] }}'
1008
+ ```
1009
+
1010
+ ### Linear
1011
+
1012
+ ```graphql
1013
+ mutation {
1014
+ commentCreate(
1015
+ input: { issueId: "$PARENT_UUID", body: "<tracking comment markdown>" }
1016
+ ) {
1017
+ success
1018
+ }
1019
+ }
1020
+ ```
1021
+
1022
+ ### Azure DevOps
1023
+
1024
+ ```bash
1025
+ curl -s \
1026
+ -u ":$AZDO_PAT" \
1027
+ -X POST \
1028
+ -H "Content-Type: application/json" \
1029
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID/comments?api-version=7.1-preview.4" \
1030
+ -d '{"text": "<tracking comment as HTML>"}'
1031
+ ```
1032
+
1033
+ Convert the tracking comment markdown to HTML (table → `<table>`, headings → `<h2>`, etc.).
1034
+
1035
+ ### Shortcut — POST comment on parent
1036
+
1037
+ If the parent is an epic:
1038
+
1039
+ ```bash
1040
+ curl -s \
1041
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
1042
+ -H "Content-Type: application/json" \
1043
+ -X POST \
1044
+ "https://api.app.shortcut.com/api/v3/epics/$EPIC_ID/comments" \
1045
+ -d '{"text": "<tracking comment markdown>"}'
1046
+ ```
1047
+
1048
+ If the parent is a story:
1049
+
1050
+ ```bash
1051
+ curl -s \
1052
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
1053
+ -H "Content-Type: application/json" \
1054
+ -X POST \
1055
+ "https://api.app.shortcut.com/api/v3/stories/$STORY_ID/comments" \
1056
+ -d '{"text": "<tracking comment markdown>"}'
1057
+ ```
1058
+
1059
+ Shortcut accepts Markdown directly in comment text.
1060
+
1061
+ ### Notion — POST comment on parent
1062
+
1063
+ ```bash
1064
+ curl -s \
1065
+ -H "Authorization: Bearer $NOTION_TOKEN" \
1066
+ -H "Notion-Version: 2022-06-28" \
1067
+ -X POST \
1068
+ "https://api.notion.com/v1/comments" \
1069
+ -d '{"parent": {"page_id": "$PARENT_PAGE_ID"}, "rich_text": [{"type": "text", "text": {"content": "<tracking comment text>"}}]}'
1070
+ ```
1071
+
1072
+ **Notion limitation:** Comments use `rich_text` blocks, not markdown. The `rich_text` array has a 2000-character limit per text block. Split the tracking table across multiple blocks if needed.
1073
+
1074
+ ### Tracking comment format
1075
+
1076
+ ```markdown
1077
+ ## Clancy — Approved Tickets
1078
+
1079
+ | # | Ticket | Title | Size | Mode |
1080
+ | --- | ------ | ------- | ---- | ---- |
1081
+ | 1 | {KEY} | {Title} | S | AFK |
1082
+ | 2 | {KEY} | {Title} | M | HITL |
1083
+ | 3 | {KEY} | {Title} | M | AFK |
1084
+
1085
+ Dependencies linked: {N}
1086
+ Created by Clancy on {YYYY-MM-DD}.
1087
+ ```
1088
+
1089
+ **On failure:** Warn: `Could not post tracking summary on {PARENT}. Tickets are created regardless.` Continue — best-effort.
1090
+
1091
+ ---
1092
+
1093
+ ## Step 11a — Remove brief label from parent
1094
+
1095
+ Only if a parent ticket exists AND all tickets were created successfully. Remove the brief label from the parent ticket. Best-effort — warn on failure, never stop.
1096
+
1097
+ Use `CLANCY_LABEL_BRIEF` from `.clancy/.env` if set, otherwise `clancy:brief`.
1098
+
1099
+ ### GitHub
1100
+
1101
+ ```bash
1102
+ curl -s \
1103
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
1104
+ -H "Accept: application/vnd.github+json" \
1105
+ -H "X-GitHub-Api-Version: 2022-11-28" \
1106
+ -X DELETE \
1107
+ "https://api.github.com/repos/$GITHUB_REPO/issues/$PARENT_NUMBER/labels/$(echo $CLANCY_LABEL_BRIEF | jq -Rr @uri)"
1108
+ ```
1109
+
1110
+ Ignore 404 (label may not be present on the parent).
1111
+
1112
+ ### Jira
1113
+
1114
+ ```bash
1115
+ CURRENT_LABELS=$(curl -s \
1116
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
1117
+ -H "Accept: application/json" \
1118
+ "$JIRA_BASE_URL/rest/api/3/issue/$PARENT_KEY?fields=labels" | jq -r '.fields.labels')
1119
+
1120
+ UPDATED_LABELS=$(echo "$CURRENT_LABELS" | jq --arg brief "$CLANCY_LABEL_BRIEF" '[.[] | select(. != $brief)]')
1121
+
1122
+ curl -s \
1123
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
1124
+ -X PUT \
1125
+ -H "Content-Type: application/json" \
1126
+ "$JIRA_BASE_URL/rest/api/3/issue/$PARENT_KEY" \
1127
+ -d "{\"fields\": {\"labels\": $UPDATED_LABELS}}"
1128
+ ```
1129
+
1130
+ ### Linear
1131
+
1132
+ ```graphql
1133
+ # Fetch current label IDs on the parent issue
1134
+ query {
1135
+ issues(filter: { identifier: { eq: "$PARENT_KEY" } }) {
1136
+ nodes {
1137
+ id
1138
+ labels {
1139
+ nodes {
1140
+ id
1141
+ name
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ # Filter out the brief label ID, then update with remaining labelIds
1149
+ mutation {
1150
+ issueUpdate(
1151
+ id: "$PARENT_ISSUE_UUID"
1152
+ input: { labelIds: [currentLabelIds, without, briefLabelId] }
1153
+ ) {
1154
+ success
1155
+ }
1156
+ }
1157
+ ```
1158
+
1159
+ ### Azure DevOps
1160
+
1161
+ ```bash
1162
+ # Fetch current tags, remove brief tag from semicolon-delimited string
1163
+ CURRENT=$(curl -s \
1164
+ -u ":$AZDO_PAT" \
1165
+ -H "Accept: application/json" \
1166
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID?fields=System.Tags&api-version=7.1")
1167
+
1168
+ curl -s \
1169
+ -u ":$AZDO_PAT" \
1170
+ -X PATCH \
1171
+ -H "Content-Type: application/json-patch+json" \
1172
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID?api-version=7.1" \
1173
+ -d '[{"op": "replace", "path": "/fields/System.Tags", "value": "<tags without brief tag>"}]'
1174
+ ```
1175
+
1176
+ ### Shortcut
1177
+
1178
+ ```bash
1179
+ # Fetch current story/epic labels, filter out brief label, update
1180
+ # For stories:
1181
+ STORY=$(curl -s \
1182
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
1183
+ "https://api.app.shortcut.com/api/v3/stories/$PARENT_STORY_ID")
1184
+
1185
+ # Remove brief label from labels array, then update:
1186
+ curl -s \
1187
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
1188
+ -H "Content-Type: application/json" \
1189
+ -X PUT \
1190
+ "https://api.app.shortcut.com/api/v3/stories/$PARENT_STORY_ID" \
1191
+ -d '{"labels": [labels_without_brief_label]}'
1192
+ ```
1193
+
1194
+ For epics, use `GET /api/v3/epics/$EPIC_ID` and `PUT /api/v3/epics/$EPIC_ID` with the same label-filtering pattern.
1195
+
1196
+ ### Notion
1197
+
1198
+ ```bash
1199
+ # Fetch current page properties, remove brief label from multi-select
1200
+ PAGE=$(curl -s \
1201
+ -H "Authorization: Bearer $NOTION_TOKEN" \
1202
+ -H "Notion-Version: 2022-06-28" \
1203
+ "https://api.notion.com/v1/pages/$PARENT_PAGE_ID")
1204
+
1205
+ # Update multi-select property without brief label
1206
+ curl -s \
1207
+ -H "Authorization: Bearer $NOTION_TOKEN" \
1208
+ -H "Notion-Version: 2022-06-28" \
1209
+ -X PATCH \
1210
+ "https://api.notion.com/v1/pages/$PARENT_PAGE_ID" \
1211
+ -d '{"properties": {"$CLANCY_NOTION_LABELS": {"multi_select": [options_without_brief_label]}}}'
1212
+ ```
1213
+
1214
+ ### On failure
1215
+
1216
+ ```
1217
+ ⚠️ Could not remove brief label from {PARENT_KEY}. Remove it manually if needed.
1218
+ ```
1219
+
1220
+ Continue — do not stop.
1221
+
1222
+ ---
1223
+
1224
+ ## Step 11b — Add plan label to parent
1225
+
1226
+ Only if a parent ticket exists AND all tickets were created successfully. Add the plan label to the parent ticket so it enters the planning queue. Best-effort — warn on failure, never stop.
1227
+
1228
+ **Crash safety:** Step 11a removes the brief label and Step 11b adds the plan label. If Step 11b fails, the parent loses its pipeline label — warn the user to add it manually.
1229
+
1230
+ Use `CLANCY_LABEL_PLAN` from `.clancy/.env` if set, otherwise `clancy:plan`.
1231
+
1232
+ ### GitHub
1233
+
1234
+ ```bash
1235
+ # Ensure plan label exists
1236
+ curl -s \
1237
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
1238
+ -H "Accept: application/vnd.github+json" \
1239
+ -H "X-GitHub-Api-Version: 2022-11-28" \
1240
+ -X POST \
1241
+ "https://api.github.com/repos/$GITHUB_REPO/labels" \
1242
+ -d "{\"name\": \"$CLANCY_LABEL_PLAN\", \"color\": \"0075ca\"}"
1243
+ # Ignore 422 (label already exists)
1244
+
1245
+ # Add label to parent
1246
+ curl -s \
1247
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
1248
+ -H "Accept: application/vnd.github+json" \
1249
+ -H "X-GitHub-Api-Version: 2022-11-28" \
1250
+ -X POST \
1251
+ "https://api.github.com/repos/$GITHUB_REPO/issues/$PARENT_NUMBER/labels" \
1252
+ -d "{\"labels\": [\"$CLANCY_LABEL_PLAN\"]}"
1253
+ ```
1254
+
1255
+ ### Jira
1256
+
1257
+ ```bash
1258
+ CURRENT_LABELS=$(curl -s \
1259
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
1260
+ -H "Accept: application/json" \
1261
+ "$JIRA_BASE_URL/rest/api/3/issue/$PARENT_KEY?fields=labels" | jq -r '.fields.labels')
1262
+
1263
+ UPDATED_LABELS=$(echo "$CURRENT_LABELS" | jq --arg plan "$CLANCY_LABEL_PLAN" '. + [$plan] | unique')
1264
+
1265
+ curl -s \
1266
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
1267
+ -X PUT \
1268
+ -H "Content-Type: application/json" \
1269
+ "$JIRA_BASE_URL/rest/api/3/issue/$PARENT_KEY" \
1270
+ -d "{\"fields\": {\"labels\": $UPDATED_LABELS}}"
1271
+ ```
1272
+
1273
+ ### Linear
1274
+
1275
+ ```graphql
1276
+ # Ensure plan label exists — check team labels, workspace labels, create if missing
1277
+ mutation {
1278
+ issueLabelCreate(input: {
1279
+ teamId: "$LINEAR_TEAM_ID"
1280
+ name: "$CLANCY_LABEL_PLAN"
1281
+ color: "#0075ca"
1282
+ }) { success issueLabel { id } }
1283
+ }
1284
+
1285
+ # Fetch current label IDs on the parent, add plan label ID
1286
+ mutation {
1287
+ issueUpdate(
1288
+ id: "$PARENT_ISSUE_UUID"
1289
+ input: { labelIds: [...currentLabelIds, planLabelId] }
1290
+ ) { success }
1291
+ }
1292
+ ```
1293
+
1294
+ ### Azure DevOps
1295
+
1296
+ ```bash
1297
+ # Fetch current tags, append plan tag
1298
+ CURRENT=$(curl -s \
1299
+ -u ":$AZDO_PAT" \
1300
+ -H "Accept: application/json" \
1301
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID?fields=System.Tags&api-version=7.1")
1302
+
1303
+ # Append plan tag to semicolon-delimited string
1304
+ curl -s \
1305
+ -u ":$AZDO_PAT" \
1306
+ -X PATCH \
1307
+ -H "Content-Type: application/json-patch+json" \
1308
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID?api-version=7.1" \
1309
+ -d '[{"op": "replace", "path": "/fields/System.Tags", "value": "<existing tags>; $CLANCY_LABEL_PLAN"}]'
1310
+ ```
1311
+
1312
+ ### Shortcut
1313
+
1314
+ ```bash
1315
+ # Resolve plan label ID (create if missing)
1316
+ LABELS=$(curl -s \
1317
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
1318
+ "https://api.app.shortcut.com/api/v3/labels")
1319
+
1320
+ # Find or create the plan label, get its ID
1321
+ # Then add to story (merge with existing labels):
1322
+ curl -s \
1323
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
1324
+ -H "Content-Type: application/json" \
1325
+ -X PUT \
1326
+ "https://api.app.shortcut.com/api/v3/stories/$PARENT_STORY_ID" \
1327
+ -d '{"labels": [existing_labels_plus_plan_label]}'
1328
+ ```
1329
+
1330
+ For epics, use `GET /api/v3/epics/$EPIC_ID` and `PUT /api/v3/epics/$EPIC_ID` with the same label pattern.
1331
+
1332
+ ### Notion
1333
+
1334
+ ```bash
1335
+ # Fetch current page properties, add plan label to multi-select
1336
+ PAGE=$(curl -s \
1337
+ -H "Authorization: Bearer $NOTION_TOKEN" \
1338
+ -H "Notion-Version: 2022-06-28" \
1339
+ "https://api.notion.com/v1/pages/$PARENT_PAGE_ID")
1340
+
1341
+ # Update multi-select property with plan label added
1342
+ curl -s \
1343
+ -H "Authorization: Bearer $NOTION_TOKEN" \
1344
+ -H "Notion-Version: 2022-06-28" \
1345
+ -X PATCH \
1346
+ "https://api.notion.com/v1/pages/$PARENT_PAGE_ID" \
1347
+ -d '{"properties": {"$CLANCY_NOTION_LABELS": {"multi_select": [existing_options_plus_plan_label]}}}'
1348
+ ```
1349
+
1350
+ ### On failure
1351
+
1352
+ If adding the plan label fails, **re-add the brief label** so the ticket isn't orphaned without any pipeline label. Use the same platform-specific API calls from Step 11a (but adding instead of removing). Then warn:
1353
+
1354
+ ```
1355
+ ⚠️ Could not add plan label to {PARENT_KEY}. Re-added brief label as fallback.
1356
+ Add the plan label manually so the ticket enters the planning queue.
1357
+ ```
1358
+
1359
+ Continue — do not stop.
1360
+
1361
+ ---
1362
+
1363
+ ## Step 12 — Display summary
1364
+
1365
+ Show the final result:
1366
+
1367
+ ```
1368
+ {N} tickets created under {PARENT_KEY}
1369
+
1370
+ {KEY} [{Size}] [{Mode}] {Title}
1371
+ {KEY} [{Size}] [{Mode}] {Title}
1372
+ {KEY} [{Size}] [{Mode}] {Title}
1373
+
1374
+ Dependencies linked: {N}
1375
+ AFK-ready: {X} | Needs human: {Y}
1376
+ Epic branch: epic/{parent-key-lowercase}
1377
+
1378
+ Next: run /clancy:plan to generate implementation plans.
1379
+
1380
+ "We've got ourselves a case."
1381
+ ```
1382
+
1383
+ If no parent:
1384
+
1385
+ ```
1386
+ {N} standalone tickets created.
1387
+ ...
1388
+ Next: run /clancy:plan to generate implementation plans.
1389
+
1390
+ "We've got ourselves a case."
1391
+ ```
1392
+
1393
+ ---
1394
+
1395
+ ## Step 13 — Log
1396
+
1397
+ Append to `.clancy/progress.txt`:
1398
+
1399
+ ```
1400
+ YYYY-MM-DD HH:MM | APPROVE_BRIEF | {slug} | {N} tickets created
1401
+ ```
1402
+
1403
+ ---
1404
+
1405
+ ## Error handling reference
1406
+
1407
+ ### Rate limiting
1408
+
1409
+ | Platform | Detection | Response |
1410
+ | ------------ | -------------------------------- | ------------------------------------------ |
1411
+ | GitHub | 403 + `X-RateLimit-Remaining: 0` | Wait until `X-RateLimit-Reset`, retry once |
1412
+ | Jira | 429 + `Retry-After` header | Wait the specified seconds, retry once |
1413
+ | Linear | `RATELIMITED` error code | Wait 60s, retry once |
1414
+ | Azure DevOps | 429 + `Retry-After` header | Wait the specified seconds, retry once |
1415
+ | Shortcut | 429 + `Retry-After` header | Wait the specified seconds, retry once |
1416
+ | Notion | 429 + `Retry-After` header | Wait the specified seconds, retry once |
1417
+
1418
+ If retry also fails: stop, enter partial failure flow.
1419
+
1420
+ ### Auth failure mid-batch
1421
+
1422
+ If a 401/403 occurs during ticket creation (token expired or revoked after preflight):
1423
+
1424
+ ```
1425
+ Auth failed during ticket creation. {M} of {N} created before failure.
1426
+ Check credentials and re-run to resume.
1427
+ ```
1428
+
1429
+ Enter partial failure flow (Step 10).
1430
+
1431
+ ### Timeout
1432
+
1433
+ | Platform | Timeout |
1434
+ | ------------ | -------------------- |
1435
+ | GitHub | 15s per API call |
1436
+ | Jira | 30s per API call |
1437
+ | Linear | 30s per GraphQL call |
1438
+ | Azure DevOps | 30s per API call |
1439
+ | Shortcut | 30s per API call |
1440
+ | Notion | 30s per API call |
1441
+
1442
+ On timeout: `Request timed out. Ticket may have been created server-side. Check board before re-run.` Enter partial failure flow.
1443
+
1444
+ ### Duplicate guard (on resume)
1445
+
1446
+ When resuming from a partial failure, before creating each ticket check the brief's decomposition table for an existing Ticket column entry. Additionally, scan the board for existing children of the parent with matching titles (case-insensitive exact match):
1447
+
1448
+ #### Jira
1449
+
1450
+ ```bash
1451
+ curl -s \
1452
+ -u "$JIRA_USER:$JIRA_API_TOKEN" \
1453
+ -H "Content-Type: application/json" \
1454
+ -H "Accept: application/json" \
1455
+ -X POST \
1456
+ "$JIRA_BASE_URL/rest/api/3/search/jql" \
1457
+ -d '{"jql": "parent = $PARENT_KEY", "maxResults": 50, "fields": ["summary"]}'
1458
+ ```
1459
+
1460
+ #### GitHub
1461
+
1462
+ ```bash
1463
+ curl -s \
1464
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
1465
+ -H "Accept: application/vnd.github+json" \
1466
+ -H "X-GitHub-Api-Version: 2022-11-28" \
1467
+ "https://api.github.com/repos/$GITHUB_REPO/issues?state=open&per_page=30"
1468
+ ```
1469
+
1470
+ Filter for issues containing `Epic: #{parent_number}` in the body.
1471
+
1472
+ #### Linear
1473
+
1474
+ ```graphql
1475
+ query {
1476
+ issues(filter: { identifier: { eq: "$PARENT_KEY" } }) {
1477
+ nodes {
1478
+ children {
1479
+ nodes {
1480
+ id
1481
+ identifier
1482
+ title
1483
+ }
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ ```
1489
+
1490
+ #### Azure DevOps
1491
+
1492
+ ```bash
1493
+ # Fetch parent work item relations to find existing children
1494
+ curl -s \
1495
+ -u ":$AZDO_PAT" \
1496
+ -H "Accept: application/json" \
1497
+ "https://dev.azure.com/$AZDO_ORG/$AZDO_PROJECT/_apis/wit/workitems/$PARENT_ID?\$expand=relations&api-version=7.1"
1498
+ ```
1499
+
1500
+ Filter `relations` array for entries with `rel: "System.LinkTypes.Hierarchy-Forward"`. Extract child work item IDs from relation URLs, batch fetch titles, and compare against proposed ticket titles (case-insensitive exact match).
1501
+
1502
+ #### Shortcut
1503
+
1504
+ If the parent is an epic, fetch its stories:
1505
+
1506
+ ```bash
1507
+ curl -s \
1508
+ -H "Shortcut-Token: $SHORTCUT_API_TOKEN" \
1509
+ "https://api.app.shortcut.com/api/v3/epics/$EPIC_ID/stories"
1510
+ ```
1511
+
1512
+ Compare story `name` fields against proposed ticket titles (case-insensitive exact match).
1513
+
1514
+ If the parent is a story, check `story_links` for related stories and compare titles.
1515
+
1516
+ #### Notion
1517
+
1518
+ If the parent page has a `relation` property for children, query related pages:
1519
+
1520
+ ```bash
1521
+ PAGE=$(curl -s \
1522
+ -H "Authorization: Bearer $NOTION_TOKEN" \
1523
+ -H "Notion-Version: 2022-06-28" \
1524
+ "https://api.notion.com/v1/pages/$PARENT_PAGE_ID")
1525
+ ```
1526
+
1527
+ Extract page IDs from the relation property, fetch each page's title, and compare against proposed ticket titles (case-insensitive exact match).
1528
+
1529
+ If no relation property is configured, query the database for pages with matching titles:
1530
+
1531
+ ```bash
1532
+ curl -s \
1533
+ -H "Authorization: Bearer $NOTION_TOKEN" \
1534
+ -H "Notion-Version: 2022-06-28" \
1535
+ -X POST \
1536
+ "https://api.notion.com/v1/databases/$NOTION_DATABASE_ID/query" \
1537
+ -d '{"filter": {"property": "Name", "title": {"equals": "{ticket title}"}}}'
1538
+ ```
1539
+
1540
+ If matching children found:
1541
+
1542
+ ```
1543
+ {PARENT} already has children with similar titles:
1544
+ - {KEY}: "{Title}" (matches proposed ticket #{N})
1545
+
1546
+ This brief may have already been approved. Continue anyway? [y/N]
1547
+ ```
1548
+
1549
+ Default: N (don't create duplicates).
1550
+
1551
+ ---
1552
+
1553
+ ## Notes
1554
+
1555
+ - The `Epic: {key}` convention is always the first line of every child ticket description across all platforms — this enables cross-platform epic completion detection by `fetchChildrenStatus`
1556
+ - Mode labels (`clancy:afk` / `clancy:hitl`) are used by `/clancy:autopilot` to decide whether to pick up a ticket autonomously or skip it for human attention
1557
+ - Jira ADF construction: if complex content fails, wrap in a `codeBlock` node as fallback (matches the pattern used by `/clancy:approve-plan`)
1558
+ - The `.approved` marker filename is the full brief filename with `.approved` appended (e.g. `.clancy/briefs/2026-03-14-auth-rework.md.approved`)
1559
+ - Tickets are created sequentially (not in parallel) to maintain dependency ordering and respect rate limits
1560
+ - The 500ms delay between ticket creations is sufficient for all platforms under normal rate limit conditions
1561
+ - Dependency links use "Blocks" for Jira, `blockedBy` for Linear, `System.LinkTypes.Dependency-Reverse` for Azure DevOps, `story_links` with `blocks` verb for Shortcut, `relation` properties for Notion, and body text cross-references for GitHub
1562
+ - Labels on Jira, Azure DevOps, and Notion are auto-created by the platform; on GitHub they must be pre-created or the 422 fallback handles it; on Linear and Shortcut they are looked up and auto-created if missing
1563
+ - Shortcut uses `Shortcut-Token` header (not `Authorization: Bearer`). API base: `https://api.app.shortcut.com/api/v3`. Stories use `name` (not `title`), descriptions use markdown, and parent linking uses `epic_id` for epic parents or `story_links` for story parents
1564
+ - Notion uses `Authorization: Bearer $NOTION_TOKEN` and `Notion-Version: 2022-06-28` headers. API base: `https://api.notion.com/v1`. Comments use `rich_text` blocks (not markdown) with a 2000-character limit per text block. Page content is blocks (not a description field). Labels use multi-select properties. The API does not support editing comments (post new instead). Parent-child relationships use `relation` properties (not native hierarchy)
1565
+ - Sprint/milestone assignment is deliberately not set — this is a team planning decision
1566
+ - Linear `priority: 0` means "No priority" — the team triages after creation
1567
+ - Jira priority is inherited from the parent if available; Linear and GitHub do not inherit priority