toga-ai 1.0.249 → 1.0.251
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/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +103 -21
- package/package.json +1 -1
- package/skills/plan-ticket/SKILL.md +191 -0
- package/skills/plan-ticket/scripts/clickup.js +140 -0
- package/skills/plan-ticket/scripts/talos.js +156 -0
- package/skills/rework-ticket/SKILL.md +203 -0
- package/skills/work-ticket/SKILL.md +214 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| [Etilize Catalog Item Import & Refresh](features/etilize-catalog-item-import.md) | Client-generic catalog onboarding from an S3 CSV plus an Etilize re-pull. | worker2/Worker/Etilize/Items.php |
|
|
14
14
|
| [Etilize Item Translation Import](features/etilize-item-translation-import.md) | The abstract worker class `_Worker_Etilize_ItemTranslations` imports **non-English** item text from Etilize into the client's `ItemTranslations` table. | worker2/Worker/Etilize/ItemTranslations.php |
|
|
15
15
|
| [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Notification/Email.php, worker2/Worker/Rate.php, dbchanges2/Core/2026-05-21 - Monitors.sql, dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql |
|
|
16
|
-
| [NetSuite
|
|
16
|
+
| [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Clickup.php, worker2/Worker/Clickup/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
17
17
|
| [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
18
18
|
| [NetSuite Supporting-Record Webhook Importer (the reusable recipe)](features/netsuite-supporting-record-webhook-importer.md) | A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record* importer (the lookup/dimension tables behind Forecast2 — Employees, | worker2/Worker/Netsuite/Employee.php, worker2/Worker/Netsuite/Account.php, worker2/Worker/Netsuite/Classification.php, worker2/Worker/Netsuite/Customer.php, worker2/Worker/Netsuite/Item.php, worker2/Worker/Netsuite.php, _underscore/Model/Forecast/Employee.php, _underscore/Model/Forecast/Account.php, _underscore/Model/Forecast/Classification.php, _underscore/Component/Forecast/Db/Db.php, test/@dave/test_employee_lifecycle.php, test/@dave/test_account_lifecycle.php, test/@dave/test_classification_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, worker/crons/toga2/forecast2/import_supporting_records.php |
|
|
19
19
|
| [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, _underscore/Model/Client/EmailTemplate.php |
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: NetSuite
|
|
2
|
+
title: NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: worker2
|
|
5
5
|
project: Worker
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-30
|
|
10
10
|
owners: ["dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite.php
|
|
13
13
|
- worker2/Worker/Netsuite/Opportunity.php
|
|
14
|
+
- worker2/Worker/Clickup.php
|
|
15
|
+
- worker2/Worker/Clickup/Opportunity.php
|
|
14
16
|
- worker2/Controller/Index.php
|
|
15
17
|
- _underscore/Worker.php
|
|
16
18
|
- test/@dave/NetSuite/api-message-queue/lib_amq_queue.js
|
|
@@ -38,7 +40,10 @@ Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer
|
|
|
38
40
|
record ("API Message Queue"), a scheduled SuiteScript drains it to
|
|
39
41
|
`webhook.togahub.com/netsuite`, and worker2 routes it to a per-recordType handler that upserts
|
|
40
42
|
into the `Forecast` DB. The legacy NetSuite→ClickUp opportunity-task creation is preserved as a
|
|
41
|
-
second, independently-gated concern in the same handler.
|
|
43
|
+
second, independently-gated concern in the same handler. As of 2026-06-30 (TRUE-79181) the ClickUp leg
|
|
44
|
+
is **bidirectional for field + stage**: ClickUp task edits flow back to the linked NetSuite opportunity
|
|
45
|
+
(`_Worker_Clickup_Opportunity`, update-only), and NS→CU now drives the ClickUp task **status** — see
|
|
46
|
+
the "CU→NS direction" section.
|
|
42
47
|
|
|
43
48
|
## Key files / entry points
|
|
44
49
|
|
|
@@ -364,29 +369,106 @@ None — platform-wide Forecast sync.
|
|
|
364
369
|
(doubled-prefix ids — see the doubled-id gotcha), which are **SuiteQL-queryable** for after-the-fact
|
|
365
370
|
diagnosis even though the calling-script log rolls off.
|
|
366
371
|
|
|
367
|
-
## CU→NS direction (
|
|
372
|
+
## CU→NS direction (BUILT 2026-06-30, TRUE-79181): reverse sync
|
|
368
373
|
|
|
369
|
-
The
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
before comparing-to / writing-to NetSuite:
|
|
374
|
+
The integration is now **bidirectional** for **field + stage**. ClickUp task edits flow back to the
|
|
375
|
+
linked NetSuite opportunity via a new handler, and NS→CU now drives the ClickUp task **status** (not
|
|
376
|
+
just description text).
|
|
373
377
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
-
|
|
379
|
-
`NetSuite Internal ID:` line. Company / Amount / Expected Close / Stage are NS-derived display, not
|
|
380
|
-
ClickUp-authored — parse them out and ignore.
|
|
381
|
-
- **Custom fields** are already discrete — no extraction.
|
|
378
|
+
**Handler:** `_Worker_Clickup_Opportunity` (`worker2/Worker/Clickup/Opportunity.php`) — an abstract
|
|
379
|
+
static-class worker, dispatched by `_Worker_Clickup::Webhook` via
|
|
380
|
+
`_Worker::runTask('Clickup/Opportunity/Process', {taskId,event,data})`. Routing is gated by the ClickUp
|
|
381
|
+
**LIST id** (Presales Qualification list `901111987449`) for `taskUpdated`/`taskStatusUpdated` events;
|
|
382
|
+
non-opportunity lists fall through to the legacy sprint handling in `Clickup.php`.
|
|
382
383
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
`
|
|
387
|
-
|
|
384
|
+
**CU→NS is UPDATE-ONLY** (never create/delete):
|
|
385
|
+
1. Resolve the linked NS opportunity from the task's `Opportunity #` custom field
|
|
386
|
+
(`a5529cdc-8dbb-4165-8bc5-9f5cc691796a`, = NS `tranId`) via a one-row SuiteQL
|
|
387
|
+
`SELECT id FROM transaction WHERE type='Opprtnty' AND tranid='<n>'`.
|
|
388
|
+
2. GET the opportunity with `expandSubResources=true` — this also supplies the **current** NS values
|
|
389
|
+
for the diff.
|
|
390
|
+
3. **No match → skip.**
|
|
391
|
+
4. PATCH **only** the NS-owned fields that actually differ (currently `title`, `stage`). A no-op write
|
|
392
|
+
fires no NS event, so the write cannot re-trigger NS→CU. **Echo prevention is the difference-check
|
|
393
|
+
alone** — there is no actor-identity / "last-synced-from" suppression field (that hardening was
|
|
394
|
+
explicitly deferred; see Decisions).
|
|
395
|
+
|
|
396
|
+
**Shared `STAGE_MAP` — single source of truth, declared on `_Worker_Netsuite_Opportunity`.** Maps NS
|
|
397
|
+
`entityStatus` internalId → ClickUp status on the Presales Qualification list (VERIFIED live 2026-06-30):
|
|
398
|
+
|
|
399
|
+
| NS internalId | NS stage | ClickUp status |
|
|
400
|
+
|---|---|---|
|
|
401
|
+
| 124 | Closed - Won | `opportunity won` |
|
|
402
|
+
| 14 | Closed Lost | `closed lost` |
|
|
403
|
+
| 125/126/122/123/128 | 10% / 25% / 50% / 75% / 95% | `in progress` |
|
|
404
|
+
| 130 | Alternate Quote | *(unmapped)* |
|
|
405
|
+
|
|
406
|
+
**CRITICAL non-1:1 gotcha:** the five probability stages all collapse to `in progress`, so the reverse
|
|
407
|
+
map `CU_STATUS_TO_NS_STAGE` only covers the **unambiguous terminal** stages (won/lost). CU→NS leaves a
|
|
408
|
+
non-terminal status alone rather than guessing a percentage. The percentage-reverse mapping is an
|
|
409
|
+
**unresolved stakeholder (Aaron) decision** — do not invent one.
|
|
410
|
+
|
|
411
|
+
**NS→CU now drives the ClickUp task STATUS** from the NS stage via `STAGE_MAP` (previously the stage was
|
|
412
|
+
only rendered in the description text). Applied on both create and update; the update PUT carries
|
|
413
|
+
**only** the changed fields (`name`/`description`/`status`) to stay echo-safe.
|
|
414
|
+
|
|
415
|
+
**Outbound NS calls** use the `Logs.Api` two-save pattern (`_Model_Core_Logs_Api`, `DB_LOGS`,
|
|
416
|
+
`DIRECTION_OUT`: save the request row, then save the response/failure row), with
|
|
417
|
+
`\Sentry\captureException($e); throw $e;` on failure so `WorkerJobs` records `isSuccess=0`. Logged
|
|
418
|
+
payloads are length-capped. NS access is the **2.0 adapter `_Component_Api_Netsuite`** (GET/PATCH +
|
|
419
|
+
SuiteQL POST with `Prefer: transient`); the **1.0 `App_Api_Netsuite_Rest` / SOAP toolkit are
|
|
420
|
+
deprecated** for production opportunity code.
|
|
421
|
+
|
|
422
|
+
### CU→NS gotchas
|
|
423
|
+
|
|
424
|
+
- **SuiteQL over `_Component_Api_Netsuite` has NO placeholder binding.** When a SuiteQL filter value
|
|
425
|
+
comes from an **external** source (here a ClickUp custom-field `tranId`, not a local PK), sanitize
|
|
426
|
+
with an **allowlist-and-REJECT-on-mismatch**, not strip-and-use:
|
|
427
|
+
`$safe = preg_replace('/[^A-Za-z0-9_-]/','',$v); if ($safe!==$v) return null;`. Reject-on-mismatch is
|
|
428
|
+
the firewall; silently stripping characters is not enough.
|
|
429
|
+
- **Validate `task_id` format at the webhook entry.** ClickUp native task ids are alphanumeric. The
|
|
430
|
+
legacy `_Worker_Clickup::Webhook` interpolates the raw `$payload->task_id` into Team-DB SQL at ~20
|
|
431
|
+
sites (a **pre-existing** SQL-injection exposure, NOT introduced here). A single input-boundary
|
|
432
|
+
format guard at the top of `Webhook()` closes that class for the raw payload id without rewriting
|
|
433
|
+
each query. **FLAG:** the broader legacy `$taskId`/`$customTaskId` raw-SQL interpolation throughout
|
|
434
|
+
`Clickup.php` is real pre-existing debt that warrants a dedicated security ticket.
|
|
435
|
+
- **ClickUp opportunity field ids:** list `901111987449` ("Presales Qualification");
|
|
436
|
+
`Opportunity #` = `a5529cdc-8dbb-4165-8bc5-9f5cc691796a`,
|
|
437
|
+
`Customer #` = `170dc118-b3fa-413b-826b-2753e9405851`.
|
|
438
|
+
|
|
439
|
+
### Deferred (documented follow-up)
|
|
440
|
+
|
|
441
|
+
- **Notes & attachments (CU→NS)** were intentionally deferred — they require NetSuite custom fields
|
|
442
|
+
(`custentity_cu_synced_notes` / `custentity_cu_synced_files`) + a File Cabinet folder that **do not
|
|
443
|
+
exist yet**, plus unconfirmed NS REST note/file shapes. Field + stage sync is bidirectional;
|
|
444
|
+
notes/attachments remain a documented follow-up.
|
|
445
|
+
- **Actor-identity echo hardening** deferred — the difference-check is the current (and sufficient for
|
|
446
|
+
field/stage) echo-prevention mechanism. A "drop events authored by our own integration user" gate is
|
|
447
|
+
the planned hardening if/when no-op-safe writes can't fully cover a future field.
|
|
448
|
+
- **Percentage reverse-mapping** (which NS percentage stage a CU `in progress` should map back to) is
|
|
449
|
+
blocked on the Aaron stakeholder decision noted above.
|
|
388
450
|
|
|
389
451
|
## Change history
|
|
452
|
+
- 2026-06-30 — **Built the CU→NS reverse sync + NS→CU stage drive (TRUE-79181) — integration now
|
|
453
|
+
bidirectional for field + stage.** New `_Worker_Clickup_Opportunity` handler
|
|
454
|
+
(`worker2/Worker/Clickup/Opportunity.php`), dispatched by `_Worker_Clickup::Webhook` via
|
|
455
|
+
`_Worker::runTask('Clickup/Opportunity/Process', …)`, gated by ClickUp **list id `901111987449`** for
|
|
456
|
+
`taskUpdated`/`taskStatusUpdated`. CU→NS is **update-only**: resolve the linked opp by the task's
|
|
457
|
+
`Opportunity #` (= `tranId`) via one-row SuiteQL, GET it (`expandSubResources=true`) for the current
|
|
458
|
+
values, PATCH only the differing NS-owned fields (`title`, `stage`). **Echo prevention = difference-check
|
|
459
|
+
only** (actor-identity hardening explicitly deferred). Added a shared `STAGE_MAP` on
|
|
460
|
+
`_Worker_Netsuite_Opportunity` (NS entityStatus internalId → ClickUp status; VERIFIED live: 124→won,
|
|
461
|
+
14→lost, 125/126/122/123/128→`in progress`, 130 unmapped) — its **five-probability-stages-collapse-to-
|
|
462
|
+
one** non-1:1 means the reverse map covers only the terminal stages; the percentage reverse-mapping is
|
|
463
|
+
an open Aaron decision. NS→CU now also drives the ClickUp task **status** from the stage (was
|
|
464
|
+
description-text only), PUT carrying only changed fields to stay echo-safe. Outbound NS calls use the
|
|
465
|
+
`Logs.Api` two-save OUT pattern + `Sentry::captureException;throw` on failure; NS access via the 2.0
|
|
466
|
+
`_Component_Api_Netsuite` adapter (1.0 `App_Api_Netsuite_Rest`/SOAP deprecated for prod). Gotchas
|
|
467
|
+
recorded: **SuiteQL has no placeholder binding** → external filter values need allowlist-**reject-on-
|
|
468
|
+
mismatch** sanitize; **validate `task_id` format at the webhook boundary** (the legacy `Clickup.php`
|
|
469
|
+
interpolates the raw payload id into Team-DB SQL at ~20 sites — pre-existing injection debt flagged for
|
|
470
|
+
a dedicated security ticket). Notes/attachments CU→NS deferred (need NetSuite custom fields +
|
|
471
|
+
File-Cabinet folder that don't exist yet). (dfranks)
|
|
390
472
|
- 2026-06-25 — **Fixed the Opportunity duplicate-row amplification + added a unique-index guard.**
|
|
391
473
|
Root cause: `_Model::load()` returns TRUE only on an **exactly-one** match (FALSE for 0 *and* 2+),
|
|
392
474
|
so once an nsId had 2 rows the `load()`-then-upsert handler could never re-find it and every
|
package/package.json
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-ticket
|
|
3
|
+
description: Build an implementation plan for a ClickUp ticket end-to-end. Invoke as `/plan-ticket <TICKET-ID>` (e.g. `/plan-ticket TRUE-79868`). Retrieves the full ticket from ClickUp, interrogates Talos (meeting-notes AI) and the local codebase for context, primes team framework knowledge by self-answering /kickoff from that gathered context, synthesizes a phased plan, saves it to test/@dave/approach/<TICKET>.md, shows a preview and iterates until you approve, then pushes the approved plan as formatted rich text to the ticket's Pseudocode field. Trigger on "/plan-ticket", "plan this ticket", "build a plan for <ticket>".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# plan-ticket — ticket → researched plan → ClickUp Pseudocode field
|
|
7
|
+
|
|
8
|
+
Given a ClickUp ticket id, produce a grounded implementation plan and write it both to
|
|
9
|
+
`test/@dave/approach/<TICKET>.md` and to the ticket's **📝 Pseudocode** custom field
|
|
10
|
+
(rendered, not raw markdown).
|
|
11
|
+
|
|
12
|
+
Helper scripts live in `scripts/` next to this file. They are run with `node`. All Talos
|
|
13
|
+
auth is automatic — see **Token handling** below; the developer should never have to paste a
|
|
14
|
+
token unless the 30-day refresh token has expired.
|
|
15
|
+
|
|
16
|
+
Environment used: `CLICKUP_API_KEY`, `CLICKUP_TEAM_ID` (already in the session env). Talos
|
|
17
|
+
tokens live in `~/.talos/credentials.json` (NOT in the repo).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Step 0 — Resolve the ticket id
|
|
22
|
+
|
|
23
|
+
The id is the argument after `/plan-ticket` (e.g. `TRUE-79868`). If none was given, ask for it.
|
|
24
|
+
|
|
25
|
+
## Step 1 — Retrieve the full ticket from ClickUp
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
node ".claude/skills/plan-ticket/scripts/clickup.js" get <TICKET>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This prints JSON: `{name, status, description, pseudocodeFieldId, fields:{...non-empty custom fields...}}`.
|
|
32
|
+
Read **all** of it — name, user story / description, and any populated custom fields (Application,
|
|
33
|
+
Requirements, Epic, Acceptance criteria, etc.). These details drive both the Talos query and the
|
|
34
|
+
codebase investigation. The **Application / Client** fields here also feed the self-answered
|
|
35
|
+
kickoff in Step 4. Note `pseudocodeFieldId` for the push in Step 7.
|
|
36
|
+
|
|
37
|
+
## Step 2 — Investigate the local codebase FIRST (before Talos)
|
|
38
|
+
|
|
39
|
+
**Do the code recon before querying Talos.** Investigating first surfaces the *specific*
|
|
40
|
+
unknowns — what already exists vs. what's build-from-scratch, FK topology, schema quirks,
|
|
41
|
+
sibling/precedent plans in `test/@dave/approach/`, the exact reference handlers the ticket
|
|
42
|
+
mirrors — which lets Step 3 ask Talos sharp, targeted questions ("a meeting discuss the DELETE
|
|
43
|
+
policy for X, given Sales/Opportunities hold RESTRICT FKs?") instead of generic ones. A generic
|
|
44
|
+
Talos query usually returns "no specific notes"; a code-informed one is far more likely to hit.
|
|
45
|
+
|
|
46
|
+
Ground everything in the real code. Prefer the TOGA `planner` agent for non-trivial tickets (it
|
|
47
|
+
reads deep docs in its own context and returns a phased plan); for small tickets, read the
|
|
48
|
+
specific files directly. Open the actual classes, schema (`dbchanges2` / the live core2 reader),
|
|
49
|
+
and reference handlers the ticket touches — do not guess paths. **Come out of this step with a
|
|
50
|
+
concrete list of open questions / decisions to put to Talos.**
|
|
51
|
+
|
|
52
|
+
## Step 3 — Interrogate Talos (meeting-notes AI), informed by the code findings
|
|
53
|
+
|
|
54
|
+
Compose the question from the ticket details **plus the specific unknowns/decisions Step 2
|
|
55
|
+
surfaced** — name the concrete choices you need ruled on (delete policy, scope boundaries,
|
|
56
|
+
owners, deadlines) and the architecture you found, so Talos can confirm/deny against real
|
|
57
|
+
meeting notes. Then:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
node ".claude/skills/plan-ticket/scripts/talos.js" query "<your code-informed question>"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The helper auto-refreshes the access token, opens a thread against DevCore, auto-accepts the
|
|
64
|
+
agent's plan-approval interrupt, and prints the assistant's answer. Capture what it finds
|
|
65
|
+
(with the meeting/date citations it gives) — and note explicitly when it finds nothing specific.
|
|
66
|
+
|
|
67
|
+
> Token handling: `talos.js` reads `~/.talos/credentials.json`, and if the cached access token
|
|
68
|
+
> is missing/expiring it calls `POST {apiHost}/v2/auth/refresh` with the stored refresh token to
|
|
69
|
+
> mint a new one (transactionId must be unique per call — the helper generates it). If the
|
|
70
|
+
> **refresh** token itself has expired (~30 days), the helper prints clear instructions: the
|
|
71
|
+
> developer logs in at `talos.togaiq.com`, grabs the `accessToken` + `refreshToken` from the
|
|
72
|
+
> browser (DevTools → Application/Local Storage, or any `api.togaiq.com` request's
|
|
73
|
+
> `Authorization` header), and runs `node scripts/talos.js set-tokens <access> <refresh>` once.
|
|
74
|
+
> Never echo tokens into chat, memory, the KB, or any committed file.
|
|
75
|
+
|
|
76
|
+
## Step 4 — Prime framework context via kickoff (self-answered from Steps 1–3)
|
|
77
|
+
|
|
78
|
+
Now that you hold the ticket (Step 1), the code recon (Step 2), and the Talos findings
|
|
79
|
+
(Step 3), invoke **`/kickoff`** to prime the team knowledge base — but **answer its interview
|
|
80
|
+
yourself from what you just gathered** so it never stops to ask the developer. Steps 1–2
|
|
81
|
+
already tell you everything kickoff's Step 2 interview needs:
|
|
82
|
+
|
|
83
|
+
- **Framework (1.0 / 2.0 / both)** — from the repos the code sweep landed in (`worker/`,
|
|
84
|
+
`library/`, `toga/`, etc. → 1.0; `worker2/`, `api2/`, `_underscore/`, the React SPAs → 2.0)
|
|
85
|
+
plus the ticket's **Application** field.
|
|
86
|
+
- **Layer (front / back / hybrid)** — from the repo types touched (React SPA → front-end;
|
|
87
|
+
PHP API/worker → back-end; both → hybrid).
|
|
88
|
+
- **Repos** — the in-scope repos the Step 2 sweep identified.
|
|
89
|
+
- **Client** — from the ticket's Application/Client field or ClickUp space; use
|
|
90
|
+
**"shared / internal"** when the ticket isn't client-specific.
|
|
91
|
+
|
|
92
|
+
Invoke kickoff with these pre-filled as the trailing argument so its interview is satisfied and
|
|
93
|
+
it goes straight to preflight + priming, e.g.:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
/kickoff <framework> <layer>, repos: <repos>, client: <client> — <ticket title>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Only stop to ask the developer if a gate answer is genuinely ambiguous** after all three
|
|
100
|
+
inputs (e.g. the code spans both frameworks and neither ticket nor Talos says which is in
|
|
101
|
+
scope). Otherwise let kickoff run unattended — it checks for harness updates, resolves the
|
|
102
|
+
load-set, and returns the `context-primer` briefing.
|
|
103
|
+
|
|
104
|
+
**Carry the primer briefing into synthesis.** The framework rules, gotchas, and client
|
|
105
|
+
variations it surfaces directly inform the plan — fold them into the **Architecture decision**
|
|
106
|
+
and **Risks/gotchas** sections in Step 5. This is the payoff of priming before synthesizing.
|
|
107
|
+
|
|
108
|
+
> Ordering note: the code sweep (Step 2) intentionally runs *before* kickoff. Kickoff's gate
|
|
109
|
+
> hook blocks reads until it primes, and doing the recon first is exactly what lets you
|
|
110
|
+
> self-answer the interview here. Once kickoff's preflight runs, the gate releases and Step 5
|
|
111
|
+
> synthesis can read freely.
|
|
112
|
+
|
|
113
|
+
## Step 5 — Synthesize the plan and save it
|
|
114
|
+
|
|
115
|
+
Write a phased plan to `test/@dave/approach/<TICKET>.md`. Structure: a **`# <TICKET> — <title>`
|
|
116
|
+
heading**, then a **one-line bold header** (`**Repos:** … · **Framework:** … · **Client:** …`,
|
|
117
|
+
plus a `· **Sibling/precedent:** <TICKET>` when one exists), then Summary · Meeting-notes
|
|
118
|
+
context (Talos, with citations + an explicit "no notes found" note where true) · What already
|
|
119
|
+
exists · Architecture decision · Phases (each: title, exact file paths, pseudocode) · Testing ·
|
|
120
|
+
Risks/gotchas · Rollout/verification · Owners/open-questions to confirm · Key files. Run any
|
|
121
|
+
relevant TOGA reviewers (php-reviewer/sql-reviewer) if code-shaped decisions warrant it.
|
|
122
|
+
|
|
123
|
+
> **ANY SQL SHOWN IN THE PLAN'S PSEUDOCODE MUST ALREADY FOLLOW THE TEAM SQL FORMATTING
|
|
124
|
+
> CONVENTIONS — NO ONE-LINE QUERIES, EVEN IN PSEUDOCODE.** WRITE EVERY QUERY MULTI-LINE:
|
|
125
|
+
> KEYWORDS UPPERCASE, EACH CLAUSE ON ITS OWN LINE, TABLE/COLUMNS INDENTED ON THEIR OWN LINES,
|
|
126
|
+
> NO `SELECT *`. THE PLAN IS THE TEMPLATE THE WORK-TICKET STEP COPIES FROM, SO A SLOPPY
|
|
127
|
+
> ONE-LINER HERE BECOMES A STANDARDS VIOLATION IN THE SHIPPED CODE.
|
|
128
|
+
|
|
129
|
+
**No point-in-time status.** Do not record what's currently deployed / Released / live (e.g. "the enqueuer is Released only on X today", "PR #123 is merged") — it's stale the moment it's written. Describe durable mechanisms, decisions, and required steps instead. (Matches the team rule against deployment status in docs.)
|
|
130
|
+
|
|
131
|
+
**Length limit: keep the plan to ≤ 150 lines.** Be terse and high-signal — favor tight
|
|
132
|
+
pseudocode and bullet lists over prose, fold related points into one line, and cut anything that
|
|
133
|
+
doesn't change what the implementer does. If the content can't fit, trim the lowest-value detail
|
|
134
|
+
(verbose rationale, repeated caveats) rather than dropping a section. After writing, verify with
|
|
135
|
+
`wc -l test/@dave/approach/<TICKET>.md` and tighten if it's over.
|
|
136
|
+
|
|
137
|
+
## Step 6 — Preview & approval loop (gate — do NOT push until approved)
|
|
138
|
+
|
|
139
|
+
**The plan does NOT go to ClickUp automatically.** After writing the `.md`, present it to the
|
|
140
|
+
developer for review and **wait for explicit approval** before Step 7.
|
|
141
|
+
|
|
142
|
+
1. Show a **preview**: the full plan content (or, if long, the Summary + Architecture decision +
|
|
143
|
+
Phase titles + Owners/open-questions, with the rest available on request) so the developer can
|
|
144
|
+
judge it in-chat. Note the line count (must be ≤150).
|
|
145
|
+
2. Prompt clearly: *"Approve this plan for the TRUE-XXXXX Pseudocode field, or tell me what to
|
|
146
|
+
change?"*
|
|
147
|
+
3. **Iterate**: incorporate the developer's comments — edit `test/@dave/approach/<TICKET>.md`,
|
|
148
|
+
re-investigate (codebase or Talos) if a change needs grounding, re-check ≤150 lines, and show
|
|
149
|
+
the updated preview. Go back and forth until the developer explicitly approves.
|
|
150
|
+
4. Only on explicit approval ("approved", "looks good", "ship it", etc.) proceed to Step 7. Do
|
|
151
|
+
**not** push on ambiguous replies — ask.
|
|
152
|
+
|
|
153
|
+
## Step 7 — Push the approved plan to the Pseudocode field (rendered)
|
|
154
|
+
|
|
155
|
+
Only after Step 6 approval. ClickUp custom text fields render formatting from a **Quill Delta**
|
|
156
|
+
in `value_richtext`, not markdown in `value`. The helper converts the markdown and pushes both:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
node ".claude/skills/plan-ticket/scripts/clickup.js" push <TICKET> "test/@dave/approach/<TICKET>.md"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
It resolves the Pseudocode field id by name, converts markdown → Delta, and POSTs
|
|
163
|
+
`{value:<plain>, value_richtext:<Delta JSON string>}`. Confirm it returns 200.
|
|
164
|
+
|
|
165
|
+
**Overwrite guard.** `push` **refuses** (exit 2) if the Pseudocode field already has content, to
|
|
166
|
+
avoid clobbering a manual/teammate edit — it prints the existing content. If that happens: show
|
|
167
|
+
the existing content to the developer, confirm it's safe to replace, and only then re-run with
|
|
168
|
+
`--force`:
|
|
169
|
+
```bash
|
|
170
|
+
node ".claude/skills/plan-ticket/scripts/clickup.js" push <TICKET> "test/@dave/approach/<TICKET>.md" --force
|
|
171
|
+
```
|
|
172
|
+
(A prior plan *we* wrote and the developer just re-approved is fine to replace — but still
|
|
173
|
+
surface it so the developer knows what's being overwritten.)
|
|
174
|
+
|
|
175
|
+
## Step 8 — Report
|
|
176
|
+
|
|
177
|
+
Summarize: ticket title, what Talos surfaced (or didn't), the plan's key decisions + any
|
|
178
|
+
owners/open questions to confirm, and the two outputs (the `.md` path + the Pseudocode field).
|
|
179
|
+
Offer `/capture` if a durable KB-worthy finding emerged.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Notes & reuse
|
|
184
|
+
|
|
185
|
+
- The markdown→Quill-Delta conversion rules and the `value_richtext` mechanism are documented in
|
|
186
|
+
the team KB (`2.0/apps/worker2/features/clickup-richtext-api.md`) and personal memory
|
|
187
|
+
(`reference_clickup_richtext_field_delta`).
|
|
188
|
+
- Talos is a LangGraph Agent Protocol server (`api.togaiq.com`); DevCore is the dev-team assistant
|
|
189
|
+
wired to the meeting-notes knowledge base. Other assistants (One/Sales/HR) are in
|
|
190
|
+
`credentials.json` under `assistants` if a different one is ever needed.
|
|
191
|
+
- `talos.js query` accepts an optional 2nd arg = assistant key (default `devcore`).
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* ClickUp helper for the plan-ticket skill.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node clickup.js get <TASK> # print ticket details + pseudocodeFieldId as JSON
|
|
7
|
+
* node clickup.js push <TASK> <md-path> # convert markdown -> Quill Delta, push to Pseudocode field
|
|
8
|
+
*
|
|
9
|
+
* Env: CLICKUP_API_KEY, CLICKUP_TEAM_ID.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
const KEY = process.env.CLICKUP_API_KEY;
|
|
14
|
+
const TEAM = process.env.CLICKUP_TEAM_ID;
|
|
15
|
+
const PSEUDOCODE_FIELD_NAME = 'Pseudocode'; // matched case-insensitively (field is "📝 Pseudocode")
|
|
16
|
+
|
|
17
|
+
function taskUrl(task, suffix = '') {
|
|
18
|
+
return `https://api.clickup.com/api/v2/task/${task}${suffix}?custom_task_ids=true&team_id=${TEAM}`;
|
|
19
|
+
}
|
|
20
|
+
async function getTask(task) {
|
|
21
|
+
const r = await fetch(taskUrl(task), { headers: { Authorization: KEY } });
|
|
22
|
+
if (!r.ok) throw new Error('ClickUp get failed (' + r.status + '): ' + (await r.text()).slice(0, 300));
|
|
23
|
+
return r.json();
|
|
24
|
+
}
|
|
25
|
+
function findPseudocodeField(t) {
|
|
26
|
+
const fields = t.custom_fields || [];
|
|
27
|
+
const wanted = PSEUDOCODE_FIELD_NAME.toLowerCase();
|
|
28
|
+
// The target is the rich TEXT field named "📝 Pseudocode" — NOT "Pseudocode Required"
|
|
29
|
+
// (checkbox), "Pseudocode Review" (number), or "Pseudocode Reviewer" (users). So require
|
|
30
|
+
// a text field whose name contains "pseudocode".
|
|
31
|
+
return fields.find(f => f.type === 'text' && (f.name || '').toLowerCase().includes(wanted))
|
|
32
|
+
// fallback: exact name match ignoring emoji/whitespace
|
|
33
|
+
|| fields.find(f => (f.name || '').toLowerCase().replace(/[^a-z]/g, '') === wanted);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ----------------------------------------------------------- markdown -> Quill Delta */
|
|
37
|
+
function inline(text) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let i = 0;
|
|
40
|
+
const push = (s, a) => { if (s) out.push(a ? { insert: s, attributes: a } : { insert: s }); };
|
|
41
|
+
while (i < text.length) {
|
|
42
|
+
const cands = [];
|
|
43
|
+
let p = text.indexOf('`', i); if (p !== -1) cands.push([p, 'code']);
|
|
44
|
+
p = text.indexOf('**', i); if (p !== -1) cands.push([p, 'bold']);
|
|
45
|
+
p = text.indexOf('[', i); if (p !== -1) cands.push([p, 'link']);
|
|
46
|
+
let it = -1;
|
|
47
|
+
for (let k = i; k < text.length; k++) { if (text[k] === '*' && text[k + 1] !== '*' && (k === 0 || text[k - 1] !== '*')) { it = k; break; } }
|
|
48
|
+
if (it !== -1) cands.push([it, 'italic']);
|
|
49
|
+
if (!cands.length) { push(text.slice(i)); break; }
|
|
50
|
+
cands.sort((a, b) => a[0] - b[0]);
|
|
51
|
+
const [pos, kind] = cands[0];
|
|
52
|
+
if (pos > i) push(text.slice(i, pos));
|
|
53
|
+
if (kind === 'code') { const e = text.indexOf('`', pos + 1); if (e === -1) { push(text.slice(pos)); break; } push(text.slice(pos + 1, e), { code: true }); i = e + 1; }
|
|
54
|
+
else if (kind === 'bold') { const e = text.indexOf('**', pos + 2); if (e === -1) { push(text.slice(pos)); break; } push(text.slice(pos + 2, e), { bold: true }); i = e + 2; }
|
|
55
|
+
else if (kind === 'italic') { const e = text.indexOf('*', pos + 1); if (e === -1) { push(text.slice(pos)); break; } push(text.slice(pos + 1, e), { italic: true }); i = e + 1; }
|
|
56
|
+
else { const m = /^\[([^\]]+)\]\(([^)]+)\)/.exec(text.slice(pos)); if (!m) { push(text.slice(pos, pos + 1)); i = pos + 1; } else { push(m[1], { link: m[2] }); i = pos + m[0].length; } }
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
function mdToDelta(md) {
|
|
61
|
+
md = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
62
|
+
const lines = md.split('\n');
|
|
63
|
+
const ops = [];
|
|
64
|
+
const pushInline = (t) => inline(t).forEach(o => ops.push(o));
|
|
65
|
+
const nl = (a) => ops.push(a ? { insert: '\n', attributes: a } : { insert: '\n' });
|
|
66
|
+
let i = 0;
|
|
67
|
+
while (i < lines.length) {
|
|
68
|
+
let line = lines[i];
|
|
69
|
+
if (/^```/.test(line)) { i++; while (i < lines.length && !/^```/.test(lines[i])) { ops.push({ insert: lines[i] }); nl({ 'code-block': true }); i++; } i++; continue; }
|
|
70
|
+
if (/^\s*\|/.test(line)) {
|
|
71
|
+
const rows = []; while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(lines[i]); i++; }
|
|
72
|
+
const parsed = rows.map(r => r.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()))
|
|
73
|
+
.filter(cells => !cells.every(c => /^:?-{2,}:?$/.test(c) || c === ''));
|
|
74
|
+
parsed.forEach((cells, idx) => { const joined = cells.filter(c => c !== '').join(' — '); pushInline(joined); nl(idx === 0 ? { bold: true } : { list: 'bullet' }); });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let m = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
78
|
+
if (m) { pushInline(m[2]); nl({ header: Math.min(m[1].length, 3) }); i++; continue; }
|
|
79
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { nl(); i++; continue; }
|
|
80
|
+
m = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
|
|
81
|
+
if (m) { const ind = Math.floor(m[1].length / 3); pushInline(m[3]); nl(ind > 0 ? { list: 'ordered', indent: ind } : { list: 'ordered' }); i++; continue; }
|
|
82
|
+
m = /^(\s*)[-*]\s+(.*)$/.exec(line);
|
|
83
|
+
if (m) { const ind = Math.floor(m[1].length / 2); pushInline(m[2]); nl(ind > 0 ? { list: 'bullet', indent: ind } : { list: 'bullet' }); i++; continue; }
|
|
84
|
+
m = /^>\s?(.*)$/.exec(line);
|
|
85
|
+
if (m) { pushInline(m[1]); nl({ blockquote: true }); i++; continue; }
|
|
86
|
+
if (line.trim() === '') { nl(); i++; continue; }
|
|
87
|
+
pushInline(line); nl(); i++;
|
|
88
|
+
}
|
|
89
|
+
const plain = ops.map(o => (typeof o.insert === 'string' ? o.insert : '')).join('');
|
|
90
|
+
return { delta: { ops }, plain };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function push(task, mdPath, force) {
|
|
94
|
+
const t = await getTask(task);
|
|
95
|
+
const field = findPseudocodeField(t);
|
|
96
|
+
if (!field) throw new Error('No "Pseudocode" custom field found on ' + task);
|
|
97
|
+
// Overwrite guard: refuse to clobber an already-populated field unless --force.
|
|
98
|
+
const existing = (field.value != null && String(field.value).trim() !== '') ? String(field.value) : '';
|
|
99
|
+
if (existing && !force) {
|
|
100
|
+
console.error('REFUSING to overwrite: "' + field.name + '" already has content (' + existing.length + ' chars).');
|
|
101
|
+
console.error('--- current field content (first 500 chars) ---');
|
|
102
|
+
console.error(existing.slice(0, 500));
|
|
103
|
+
console.error('--- re-run with --force to replace ---');
|
|
104
|
+
process.exitCode = 2;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const md = fs.readFileSync(mdPath, 'utf8');
|
|
108
|
+
const { delta, plain } = mdToDelta(md);
|
|
109
|
+
const url = `https://api.clickup.com/api/v2/task/${task}/field/${field.id}?custom_task_ids=true&team_id=${TEAM}`;
|
|
110
|
+
const body = JSON.stringify({ value: plain, value_richtext: JSON.stringify(delta) });
|
|
111
|
+
const r = await fetch(url, { method: 'POST', headers: { Authorization: KEY, 'Content-Type': 'application/json' }, body });
|
|
112
|
+
console.log('push ->', r.status, 'field', field.name, '(' + field.id + ')', 'ops', delta.ops.length, 'plain', plain.length);
|
|
113
|
+
if (!r.ok) { console.error(await r.text()); process.exitCode = 1; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
(async () => {
|
|
117
|
+
const argv = process.argv.slice(2);
|
|
118
|
+
const force = argv.includes('--force');
|
|
119
|
+
const [cmd, task, arg] = argv.filter(a => a !== '--force');
|
|
120
|
+
if (!KEY || !TEAM) { console.error('ERROR: CLICKUP_API_KEY / CLICKUP_TEAM_ID not set'); process.exit(1); }
|
|
121
|
+
try {
|
|
122
|
+
if (cmd === 'get') {
|
|
123
|
+
const t = await getTask(task);
|
|
124
|
+
const field = findPseudocodeField(t);
|
|
125
|
+
const fields = {};
|
|
126
|
+
(t.custom_fields || []).forEach(f => { if (f.value !== undefined && f.value !== null && f.value !== '') fields[f.name] = f.value; });
|
|
127
|
+
console.log(JSON.stringify({
|
|
128
|
+
id: t.id, custom_id: t.custom_id, name: t.name, status: t.status?.status,
|
|
129
|
+
description: t.description || t.text_content || '',
|
|
130
|
+
pseudocodeFieldId: field?.id || null,
|
|
131
|
+
url: t.url, fields,
|
|
132
|
+
}, null, 2));
|
|
133
|
+
} else if (cmd === 'push') {
|
|
134
|
+
if (!arg) throw new Error('Usage: push <TASK> <md-path> [--force]');
|
|
135
|
+
await push(task, arg, force);
|
|
136
|
+
} else {
|
|
137
|
+
console.log('commands: get <TASK> | push <TASK> <md-path> [--force]');
|
|
138
|
+
}
|
|
139
|
+
} catch (e) { console.error('ERROR:', e.message); process.exitCode = 1; }
|
|
140
|
+
})();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Talos (TOGa IQ) helper for the plan-ticket skill.
|
|
4
|
+
*
|
|
5
|
+
* Auth is automatic: reads ~/.talos/credentials.json and refreshes the short-lived (1h)
|
|
6
|
+
* access token from the stored 30-day refresh token via POST {apiHost}/v2/auth/refresh.
|
|
7
|
+
* The developer only ever pastes tokens once (set-tokens), and again only if the refresh
|
|
8
|
+
* token itself expires (~30 days).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node talos.js token # print a valid access token (refresh if needed)
|
|
12
|
+
* node talos.js status # show token expiries
|
|
13
|
+
* node talos.js set-tokens <access> <refresh>
|
|
14
|
+
* node talos.js query "<question>" [assistantKey=devcore]
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const CRED_DIR = path.join(os.homedir(), '.talos');
|
|
22
|
+
const CRED_PATH = path.join(CRED_DIR, 'credentials.json');
|
|
23
|
+
|
|
24
|
+
function load() {
|
|
25
|
+
if (!fs.existsSync(CRED_PATH)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'No Talos credentials at ' + CRED_PATH + '. Log in at talos.togaiq.com, copy the ' +
|
|
28
|
+
'accessToken + refreshToken (DevTools → Local Storage, or an api.togaiq.com request ' +
|
|
29
|
+
'Authorization header), then run: node talos.js set-tokens <access> <refresh>'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return JSON.parse(fs.readFileSync(CRED_PATH, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
function save(c) {
|
|
35
|
+
fs.mkdirSync(CRED_DIR, { recursive: true });
|
|
36
|
+
fs.writeFileSync(CRED_PATH, JSON.stringify(c, null, 2));
|
|
37
|
+
}
|
|
38
|
+
function jwtExp(jwt) {
|
|
39
|
+
try { return JSON.parse(Buffer.from(jwt.split('.')[1], 'base64').toString()).exp || 0; }
|
|
40
|
+
catch { return 0; }
|
|
41
|
+
}
|
|
42
|
+
function now() { return Math.floor(Date.now() / 1000); }
|
|
43
|
+
|
|
44
|
+
async function refresh(c) {
|
|
45
|
+
if (!c.refreshToken) throw new Error('No refresh token stored — run set-tokens.');
|
|
46
|
+
if (c.refreshExp && c.refreshExp <= now()) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'Talos REFRESH token expired. Log in at talos.togaiq.com, grab a fresh accessToken + ' +
|
|
49
|
+
'refreshToken, and run: node talos.js set-tokens <access> <refresh>'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const host = c.apiHost || 'https://api-writer.togahub.com';
|
|
53
|
+
const tid = crypto.randomUUID();
|
|
54
|
+
const r = await fetch(`${host}/v2/auth/refresh?transactionId=${tid}`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Authorization': 'Bearer ' + c.refreshToken, 'Content-Type': 'application/json' },
|
|
57
|
+
});
|
|
58
|
+
const j = await r.json().catch(() => ({}));
|
|
59
|
+
if (!j.isSuccess || !j.data?.tokens?.access) {
|
|
60
|
+
throw new Error('Refresh failed (' + r.status + '): ' + JSON.stringify(j.messages || j).slice(0, 300) +
|
|
61
|
+
' — if the refresh token is expired, run set-tokens with fresh values.');
|
|
62
|
+
}
|
|
63
|
+
c.accessToken = j.data.tokens.access;
|
|
64
|
+
c.accessExp = jwtExp(c.accessToken);
|
|
65
|
+
if (j.data.tokens.refresh) { c.refreshToken = j.data.tokens.refresh; c.refreshExp = jwtExp(c.refreshToken); }
|
|
66
|
+
save(c);
|
|
67
|
+
return c.accessToken;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function getToken() {
|
|
71
|
+
const c = load();
|
|
72
|
+
// valid if present and not expiring within 120s
|
|
73
|
+
if (c.accessToken && c.accessExp && c.accessExp - now() > 120) return c.accessToken;
|
|
74
|
+
return refresh(c);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function query(question, assistantKey) {
|
|
78
|
+
const c = load();
|
|
79
|
+
const token = await getToken();
|
|
80
|
+
const talos = c.talosHost || 'https://api.togaiq.com';
|
|
81
|
+
const assistants = c.assistants || {};
|
|
82
|
+
const assistant = assistants[assistantKey || 'devcore'] || assistants.devcore;
|
|
83
|
+
if (!assistant) throw new Error('No assistant id configured for "' + (assistantKey || 'devcore') + '"');
|
|
84
|
+
const H = () => ({ 'Authorization': 'Bearer ' + (c.accessToken || token), 'Content-Type': 'application/json' });
|
|
85
|
+
|
|
86
|
+
// create thread
|
|
87
|
+
let r = await fetch(`${talos}/threads`, { method: 'POST', headers: H(),
|
|
88
|
+
body: JSON.stringify({ metadata: { source: 'plan-ticket-skill' } }) });
|
|
89
|
+
if (r.status === 401) { await refresh(c); r = await fetch(`${talos}/threads`, { method: 'POST', headers: H(), body: JSON.stringify({ metadata: { source: 'plan-ticket-skill' } }) }); }
|
|
90
|
+
const th = await r.json();
|
|
91
|
+
const tid = th.thread_id;
|
|
92
|
+
if (!tid) throw new Error('Could not create thread: ' + JSON.stringify(th).slice(0, 300));
|
|
93
|
+
|
|
94
|
+
const extract = (j) => {
|
|
95
|
+
const msgs = j.messages || j.values?.messages;
|
|
96
|
+
if (Array.isArray(msgs)) {
|
|
97
|
+
const last = [...msgs].reverse().find(m => m.type === 'ai' || m.role === 'assistant');
|
|
98
|
+
if (last) return typeof last.content === 'string' ? last.content : JSON.stringify(last.content);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
const isInterrupt = (j) => j.__interrupt__ || (Array.isArray(j.tasks) && j.tasks.some(t => t.interrupts?.length));
|
|
103
|
+
|
|
104
|
+
// initial run
|
|
105
|
+
let body = { assistant_id: assistant, input: { messages: [{ role: 'user', content: question }] } };
|
|
106
|
+
r = await fetch(`${talos}/threads/${tid}/runs/wait`, { method: 'POST', headers: H(), body: JSON.stringify(body) });
|
|
107
|
+
let j = await r.json().catch(() => ({}));
|
|
108
|
+
|
|
109
|
+
// auto-accept up to 5 plan-approval interrupts
|
|
110
|
+
for (let i = 0; i < 5 && isInterrupt(j); i++) {
|
|
111
|
+
body = { assistant_id: assistant, command: { resume: [{ type: 'accept', args: null }] } };
|
|
112
|
+
r = await fetch(`${talos}/threads/${tid}/runs/wait`, { method: 'POST', headers: H(), body: JSON.stringify(body) });
|
|
113
|
+
j = await r.json().catch(() => ({}));
|
|
114
|
+
}
|
|
115
|
+
const answer = extract(j);
|
|
116
|
+
return { thread_id: tid, answer: answer || JSON.stringify(j).slice(0, 1500) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
(async () => {
|
|
120
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
121
|
+
try {
|
|
122
|
+
if (cmd === 'set-tokens') {
|
|
123
|
+
const [access, refreshTok] = rest;
|
|
124
|
+
if (!access || !refreshTok) throw new Error('Usage: set-tokens <access> <refresh>');
|
|
125
|
+
const c = fs.existsSync(CRED_PATH) ? JSON.parse(fs.readFileSync(CRED_PATH, 'utf8')) : {};
|
|
126
|
+
c.accessToken = access; c.accessExp = jwtExp(access);
|
|
127
|
+
c.refreshToken = refreshTok; c.refreshExp = jwtExp(refreshTok);
|
|
128
|
+
c.apiHost = c.apiHost || 'https://api-writer.togahub.com';
|
|
129
|
+
c.talosHost = c.talosHost || 'https://api.togaiq.com';
|
|
130
|
+
c.assistants = c.assistants || {
|
|
131
|
+
devcore: 'a5b1833d-b47c-5cb3-8cda-963f1cc74a33',
|
|
132
|
+
one: 'c6def0ad-659f-5aa8-b723-4a5a7d04578a',
|
|
133
|
+
sales: '842d173f-c084-583d-9eaf-ea692dd18356',
|
|
134
|
+
hr: 'eba7ca71-9b3f-52fe-b5bf-3ebd58aab720',
|
|
135
|
+
};
|
|
136
|
+
save(c);
|
|
137
|
+
console.log('Stored. accessExp', new Date(c.accessExp * 1000).toISOString(), 'refreshExp', new Date(c.refreshExp * 1000).toISOString());
|
|
138
|
+
} else if (cmd === 'status') {
|
|
139
|
+
const c = load();
|
|
140
|
+
console.log('access exp:', c.accessExp ? new Date(c.accessExp * 1000).toISOString() : 'none', c.accessExp && c.accessExp > now() ? '(valid)' : '(expired/none)');
|
|
141
|
+
console.log('refresh exp:', c.refreshExp ? new Date(c.refreshExp * 1000).toISOString() : 'none', c.refreshExp && c.refreshExp > now() ? '(valid)' : '(EXPIRED — re-paste)');
|
|
142
|
+
} else if (cmd === 'token') {
|
|
143
|
+
console.log(await getToken());
|
|
144
|
+
} else if (cmd === 'query') {
|
|
145
|
+
const out = await query(rest[0], rest[1]);
|
|
146
|
+
console.log('THREAD:', out.thread_id);
|
|
147
|
+
console.log('=== TALOS ANSWER ===');
|
|
148
|
+
console.log(out.answer);
|
|
149
|
+
} else {
|
|
150
|
+
console.log('commands: token | status | set-tokens <access> <refresh> | query "<q>" [assistantKey]');
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
console.error('ERROR:', e.message);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rework-ticket
|
|
3
|
+
description: Apply review feedback to an already-PR'd ClickUp ticket. Invoke as `/rework-ticket <TICKET-ID>` (e.g. `/rework-ticket TRUE-79162`) AFTER `/work-ticket` has opened PRs and a reviewer has left feedback. Finds the ticket's existing branches/PRs across the relevant repos, gathers every feedback source (ClickUp comments + GitHub PR reviews, inline review comments, and failing checks), primes framework context via /kickoff (self-answered), checks out each existing branch, implements the requested changes with TOGA reviewers, then pushes back to the SAME branch so the SAME PR updates — never opens a new PR. Replies on the ticket with what was addressed and stops; never changes ClickUp ticket status. Trigger on "/rework-ticket", "rework the ticket", "apply the PR feedback for <ticket>", "address review comments on <ticket>", "revise <ticket>".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# rework-ticket — gather feedback → revise the existing branch → push to the SAME PR
|
|
7
|
+
|
|
8
|
+
The revision-loop companion to **`/work-ticket`**. Where `work-ticket` takes an approved plan and
|
|
9
|
+
produces branches + PRs, `rework-ticket` takes the **feedback left on those PRs and the ticket** and
|
|
10
|
+
revises the **existing** branches in place: it discovers the ticket's branches/PRs, reads every
|
|
11
|
+
feedback channel, primes context, checks out each branch, implements the requested changes (with
|
|
12
|
+
TOGA reviewers), and **pushes back to the same branch** so the **same PR** updates. It never opens a
|
|
13
|
+
new PR and never changes ClickUp ticket status.
|
|
14
|
+
|
|
15
|
+
It runs autonomously through the push, then **stops**. The developer re-requests review; moving the
|
|
16
|
+
ticket forward is always a manual developer action.
|
|
17
|
+
|
|
18
|
+
Helper scripts are reused from the sibling `plan-ticket` skill (`scripts/clickup.js`, `get` only).
|
|
19
|
+
`gh` is already authenticated. Environment used: `CLICKUP_API_KEY`, `CLICKUP_TEAM_ID`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Step 0 — Resolve the ticket id
|
|
24
|
+
|
|
25
|
+
The id is the argument after `/rework-ticket` (e.g. `TRUE-79162`). If none was given, ask for it.
|
|
26
|
+
The **bare ticket id is the branch name** `work-ticket` used (`TRUE-79162`), so it is also the key
|
|
27
|
+
for finding the branches and PRs to revise.
|
|
28
|
+
|
|
29
|
+
## Step 1 — Load the ticket and discover its branches + PRs
|
|
30
|
+
|
|
31
|
+
1. **Load the ticket** for title, status, url, and the internal `CU-<id>`:
|
|
32
|
+
```bash
|
|
33
|
+
node ".claude/skills/plan-ticket/scripts/clickup.js" get <TICKET>
|
|
34
|
+
```
|
|
35
|
+
Capture `name` (title), `url`, the internal `id` (the `CU-<id>` form), and — if present — the
|
|
36
|
+
**📝 Pseudocode** field, whose `Repos:` header lists the in-scope repos. The Pseudocode field is
|
|
37
|
+
the plan that the PRs implement; use its `Repos:` line as the candidate repo set.
|
|
38
|
+
|
|
39
|
+
2. **Find the existing PRs/branches.** The authoritative list is usually the PR-links comment
|
|
40
|
+
`work-ticket` posted on the ticket (Step 1's comment fetch below will surface it). Confirm each by
|
|
41
|
+
asking GitHub directly — for every candidate repo from the `Repos:` header, list any PR whose head
|
|
42
|
+
branch is the bare ticket id:
|
|
43
|
+
```bash
|
|
44
|
+
gh pr list --repo <owner/repo> --head <TICKET> --state all \
|
|
45
|
+
--json number,title,state,headRefName,url,isDraft
|
|
46
|
+
```
|
|
47
|
+
Build a map `{repo, owner/repo, prNumber, branch:<TICKET>, repo-path}`. Resolve each repo's local
|
|
48
|
+
path from its `repo-path-<repo>` memory (never guess). A repo in the plan with **no** matching PR
|
|
49
|
+
is simply not in scope for this rework — note it, don't invent one.
|
|
50
|
+
|
|
51
|
+
> **The `test` repo has no PR** — `work-ticket` commits its harnesses straight to `master`
|
|
52
|
+
> ([[feedback_test_repo_commit_to_master]]). If feedback touches a `test/@dave` tool, revise it on
|
|
53
|
+
> `master` directly (commit + push to master), not via a branch/PR.
|
|
54
|
+
|
|
55
|
+
## Step 2 — Gather EVERY feedback source (this is the heart of the skill)
|
|
56
|
+
|
|
57
|
+
Do not act on a single channel — collect them all, then synthesize one concrete change-list. Read,
|
|
58
|
+
in your own analysis (delegate the heavy reading to a subagent if it is large):
|
|
59
|
+
|
|
60
|
+
1. **ClickUp comments** (the reliable channel — `work-ticket` always posts here):
|
|
61
|
+
```bash
|
|
62
|
+
node -e 'const k=process.env.CLICKUP_API_KEY,t=process.env.CLICKUP_TEAM_ID;
|
|
63
|
+
fetch("https://api.clickup.com/api/v2/task/<TICKET>/comment?custom_task_ids=true&team_id="+t,
|
|
64
|
+
{headers:{Authorization:k}}).then(r=>r.json()).then(d=>console.log(JSON.stringify(
|
|
65
|
+
(d.comments||[]).map(c=>({by:c.user&&c.user.username,date:c.date,text:c.comment_text})),null,2)))'
|
|
66
|
+
```
|
|
67
|
+
Read newest-last; the developer's review notes and any "please change X" land here.
|
|
68
|
+
|
|
69
|
+
2. **GitHub PR feedback** — for each PR from Step 1, pull all three sub-channels:
|
|
70
|
+
```bash
|
|
71
|
+
# PR-level reviews (APPROVED / CHANGES_REQUESTED + the review body) and top-level conversation
|
|
72
|
+
gh pr view <prNumber> --repo <owner/repo> \
|
|
73
|
+
--json title,body,state,reviewDecision,reviews,comments
|
|
74
|
+
# Inline code-review comments (path + line + body) -- the line-by-line asks
|
|
75
|
+
gh api repos/<owner/repo>/pulls/<prNumber>/comments \
|
|
76
|
+
--jq '.[] | {path, line, body, user: .user.login}'
|
|
77
|
+
# Failing CI/checks are also feedback to fix
|
|
78
|
+
gh pr checks <prNumber> --repo <owner/repo>
|
|
79
|
+
```
|
|
80
|
+
Treat `reviewDecision: CHANGES_REQUESTED`, any unresolved inline comment, and any failing check as
|
|
81
|
+
a required change. A review thread phrased as a question is still a change request — address it in
|
|
82
|
+
code or answer it in your reply (Step 6).
|
|
83
|
+
|
|
84
|
+
3. **Synthesize a change-list.** Produce a concrete, deduplicated list: for each item, the **repo +
|
|
85
|
+
file:line**, what is being asked, and how you will satisfy it. If two channels conflict, the most
|
|
86
|
+
recent **ClickUp comment from the developer wins** (it is the human decision); note the conflict.
|
|
87
|
+
If any feedback is genuinely ambiguous and changes what you build, ask the developer **once** (a
|
|
88
|
+
single `AskUserQuestion`) before editing — otherwise proceed.
|
|
89
|
+
|
|
90
|
+
## Step 3 — Prime framework context via kickoff (self-answered)
|
|
91
|
+
|
|
92
|
+
Same pattern as `work-ticket` Step 2: invoke **`/kickoff`** and answer its interview yourself from the
|
|
93
|
+
plan's `Repos:` header (map repos → framework/layer/client), passing the change-list as the task:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
/kickoff <framework> <layer>, repos: <repos>, client: <client> — rework <TICKET> <title>: <one-line change summary>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Carry the `context-primer` briefing into Step 5. Only stop to ask if a kickoff gate answer is
|
|
100
|
+
genuinely ambiguous.
|
|
101
|
+
|
|
102
|
+
## Step 4 — Check out each EXISTING branch (fetch first; do NOT create a new branch)
|
|
103
|
+
|
|
104
|
+
For each repo in the Step 1 map, inside that repo's path:
|
|
105
|
+
|
|
106
|
+
1. **Confirm a clean tree** — `git -C "<repo-path>" status --porcelain` should be empty (or contain
|
|
107
|
+
only files you are about to rework). Stop and surface anything unexpected rather than committing
|
|
108
|
+
over a stranger's uncommitted work.
|
|
109
|
+
2. **Fetch and check out the ticket branch, then fast-forward to the PR head:**
|
|
110
|
+
```bash
|
|
111
|
+
git -C "<repo-path>" fetch origin --quiet
|
|
112
|
+
git -C "<repo-path>" checkout TRUE-XXXX # creates a local tracking branch from origin/TRUE-XXXX if needed
|
|
113
|
+
git -C "<repo-path>" pull --ff-only origin TRUE-XXXX
|
|
114
|
+
```
|
|
115
|
+
This is the key difference from `work-ticket`: the branch **already exists on the remote** (it is
|
|
116
|
+
the open PR's head) — you must land your revisions **on that same branch**, not a fresh one. So you
|
|
117
|
+
check it out and pull the current PR head; you do **not** `checkout -b` and you do **not** re-base
|
|
118
|
+
it onto `origin/<default>` (that would rewrite the PR history). If `--ff-only` is rejected because
|
|
119
|
+
the remote branch moved in a way local can't fast-forward, stop and surface it — do not force
|
|
120
|
+
anything.
|
|
121
|
+
- For the **`test` repo**, there is no branch: `git checkout master && git pull --ff-only`.
|
|
122
|
+
|
|
123
|
+
## Step 5 — Implement the change-list, then verify
|
|
124
|
+
|
|
125
|
+
Apply the synthesized changes against the real files the feedback names. Drive non-trivial work as
|
|
126
|
+
implement → verify with subagents so the conversation stays the conductor (same discipline as
|
|
127
|
+
`work-ticket` Step 3):
|
|
128
|
+
|
|
129
|
+
1. Make the edits the feedback requires — and **only** those (rework is scoped to the feedback; do not
|
|
130
|
+
re-architect untouched code).
|
|
131
|
+
2. After each PHP file, run the TOGA reviewers (`php-reviewer`; `sql-reviewer` for SQL/schema;
|
|
132
|
+
`security-reviewer` when warranted) — **maker ≠ checker** — and loop until clean. Lint with the
|
|
133
|
+
right binary (`php -l`; 1.0 repos are PHP 7.2 → `C:\xampp7\php\php.exe -l`).
|
|
134
|
+
3. Honor team rules throughout: parameterized queries only, no bare catch / `@`, named constants over
|
|
135
|
+
magic numbers, return-type declarations, no commented-out code, no secrets or absolute local paths.
|
|
136
|
+
|
|
137
|
+
> **ALL SQL MUST FOLLOW THE TEAM SQL FORMATTING CONVENTIONS — NO EXCEPTIONS.** Every query is
|
|
138
|
+
> multi-line: keywords uppercase, each clause and column on its own line, no `SELECT *`. Run
|
|
139
|
+
> `sql-reviewer` on every file that contains SQL and fix formatting before committing.
|
|
140
|
+
|
|
141
|
+
## Step 6 — Commit and push to the SAME branch (updates the SAME PR)
|
|
142
|
+
|
|
143
|
+
For each reworked repo, inside its path:
|
|
144
|
+
|
|
145
|
+
1. **Commit.** Stage only the files the feedback changed. Message format `type: short description`
|
|
146
|
+
(`feat`/`fix`/`refactor`/`docs`/`test`/`chore`, lowercase, present tense, ≤72 chars) — for a
|
|
147
|
+
rework, `fix:` or `refactor:` addressing the review is usual; reference what was addressed in the
|
|
148
|
+
body. **Pre-commit check:** `php -l` passes, no commented-out code, no credentials/local paths.
|
|
149
|
+
2. **Push to the existing branch** — this updates the open PR in place; **do NOT open a new PR**:
|
|
150
|
+
```bash
|
|
151
|
+
git -C "<repo-path>" push origin TRUE-XXXX
|
|
152
|
+
```
|
|
153
|
+
**Never force-push** (per `git-workflow.md`) — a plain push appends commits the reviewer can see as
|
|
154
|
+
"changes since last review." For the `test` repo: `git push origin master`.
|
|
155
|
+
|
|
156
|
+
Confirm with `gh pr view <prNumber> --repo <owner/repo> --json url,state` that the PR picked up the
|
|
157
|
+
new commits (its head SHA advanced). Do **not** create a PR.
|
|
158
|
+
|
|
159
|
+
## Step 6b — Reply on the ticket (and optionally the PR) — do NOT skip
|
|
160
|
+
|
|
161
|
+
Post a ClickUp comment summarizing what was addressed, so the reviewer knows the rework landed
|
|
162
|
+
(the same dependable channel `work-ticket` uses):
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
node -e 'const k=process.env.CLICKUP_API_KEY,t=process.env.CLICKUP_TEAM_ID;
|
|
166
|
+
const body="Reworked TRUE-XXXX per review feedback:\n• <repo>: <what changed> (pushed to PR <url>)\n• ...";
|
|
167
|
+
fetch("https://api.clickup.com/api/v2/task/TRUE-XXXX/comment?custom_task_ids=true&team_id="+t,
|
|
168
|
+
{method:"POST",headers:{Authorization:k,"Content-Type":"application/json"},
|
|
169
|
+
body:JSON.stringify({comment_text:body,notify_all:false})}).then(r=>r.text()).then(console.log)'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Optionally reply to specific inline review threads so each reviewer comment is visibly answered:
|
|
173
|
+
`gh api repos/<owner/repo>/pulls/<prNumber>/comments/<commentId>/replies -f body="Done in <sha> — …"`
|
|
174
|
+
(or a top-level `gh pr comment <prNumber> --repo <owner/repo> --body "Addressed: …"`).
|
|
175
|
+
|
|
176
|
+
## Step 7 — Report & stop (do NOT touch ClickUp status)
|
|
177
|
+
|
|
178
|
+
Summarize for the developer:
|
|
179
|
+
- Ticket title and the repos that were reworked.
|
|
180
|
+
- For each repo: the branch (`TRUE-XXXX`), the **PR url**, and the new commit(s) pushed.
|
|
181
|
+
- The feedback items addressed (and any you answered in a reply rather than code), plus any conflict
|
|
182
|
+
you resolved by deferring to the latest developer comment.
|
|
183
|
+
- Confirm the Step 6b comment was posted.
|
|
184
|
+
- **Remind the developer the next step is theirs:** re-request review / re-approve, then **manually
|
|
185
|
+
move the ClickUp ticket forward** — this skill never changes ticket status.
|
|
186
|
+
|
|
187
|
+
Offer `/capture` if a durable, KB-worthy finding emerged while reworking.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Notes
|
|
192
|
+
|
|
193
|
+
- **Same branch, same PR — always.** The defining rule: rework lands on the existing ticket branch and
|
|
194
|
+
updates the existing PR. Never `checkout -b`, never open a second PR, never force-push. (This is the
|
|
195
|
+
mirror image of `work-ticket`, which cuts a fresh branch from `origin/<default>`.)
|
|
196
|
+
- **Feedback is multi-channel.** ClickUp comments + PR reviews + inline review comments + failing CI
|
|
197
|
+
checks are all feedback. Gather all of them before editing; the most recent developer ClickUp
|
|
198
|
+
comment breaks ties.
|
|
199
|
+
- **Scope is the feedback, nothing more.** Do not refactor or "improve" code the review did not raise.
|
|
200
|
+
- **Run AFTER `/work-ticket`.** This skill assumes branches/PRs already exist. If no PR matches the
|
|
201
|
+
ticket, stop and point the developer at `/work-ticket <TICKET>` first.
|
|
202
|
+
- **Autonomy boundary:** autonomous through the push + ticket reply; stops there. Moving the ClickUp
|
|
203
|
+
ticket is always a manual developer action.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: work-ticket
|
|
3
|
+
description: Work an APPROVED ClickUp ticket plan end-to-end. Invoke as `/work-ticket <TICKET-ID>` (e.g. `/work-ticket TRUE-79868`) AFTER `/plan-ticket` has pushed an approved plan to the ticket's Pseudocode field. Loads the plan from the ticket's Pseudocode field, primes framework context via /kickoff (self-answered), implements the plan phase-by-phase with TOGA reviewers, then per repo creates a bare ticket-ID branch, commits, pushes, opens a PR, and posts the PR links as a comment on the ticket. Stops after PRs are open for your review — never changes ClickUp ticket status. Trigger on "/work-ticket", "work the ticket", "execute the plan for <ticket>", "build and PR <ticket>", "ship <ticket>".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# work-ticket — approved plan → code → branch → commit → PR → ClickUp GitHub tab
|
|
7
|
+
|
|
8
|
+
The downstream companion to **`/plan-ticket`**. Given a ticket whose plan has already been
|
|
9
|
+
approved and pushed to its **📝 Pseudocode** field, this skill executes that plan: it primes
|
|
10
|
+
context, writes the code, then for **each repo the plan touches** creates a branch named the
|
|
11
|
+
**bare ticket id**, commits, pushes, opens a PR, and posts the PR links as a ticket comment.
|
|
12
|
+
ClickUp's native GitHub integration *may* surface those PRs in the ticket's **GitHub tab** when
|
|
13
|
+
the repo is connected in ClickUp's GitHub settings (see Step 5b — not guaranteed), because the
|
|
14
|
+
ticket id is in the
|
|
15
|
+
branch name (and PR title/body).
|
|
16
|
+
|
|
17
|
+
It runs autonomously through **PR creation**, then **stops**. The developer reviews the PR(s);
|
|
18
|
+
**moving the ClickUp ticket forward for approval is a manual step the developer does — this
|
|
19
|
+
skill never changes ticket status.**
|
|
20
|
+
|
|
21
|
+
Helper scripts are reused from the sibling `plan-ticket` skill (`scripts/clickup.js`). `gh` is
|
|
22
|
+
already authenticated. Environment used: `CLICKUP_API_KEY`, `CLICKUP_TEAM_ID` (already in the
|
|
23
|
+
session env).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Step 0 — Resolve the ticket id
|
|
28
|
+
|
|
29
|
+
The id is the argument after `/work-ticket` (e.g. `TRUE-79868`). If none was given, ask for it.
|
|
30
|
+
|
|
31
|
+
## Step 1 — Load the approved plan from the ticket's Pseudocode field
|
|
32
|
+
|
|
33
|
+
The **Pseudocode field is the source of truth** (not the local `test/@dave/approach/<TICKET>.md`,
|
|
34
|
+
which may be stale or absent on this machine):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
node ".claude/skills/plan-ticket/scripts/clickup.js" get <TICKET>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This prints JSON `{name, status, description, pseudocodeFieldId, url, fields:{...}}`. The
|
|
41
|
+
approved plan is the value in `fields` whose key contains **"Pseudocode"** (case-insensitive)
|
|
42
|
+
— that is the plain-text rendering of the plan pushed by `plan-ticket`.
|
|
43
|
+
|
|
44
|
+
- **If the Pseudocode field is empty / absent** — there is no approved plan. **Stop** and tell
|
|
45
|
+
the developer to run `/plan-ticket <TICKET>` and approve it first. Do not invent a plan.
|
|
46
|
+
- Parse the plan's one-line header `**Repos:** … · **Framework:** … · **Client:** …` and its
|
|
47
|
+
**Phases** (each carries exact file paths + pseudocode). Capture `name` (ticket title) for the
|
|
48
|
+
commit messages and PR titles, and the in-scope **repos**.
|
|
49
|
+
|
|
50
|
+
## Step 2 — Prime framework context via kickoff (self-answered from the plan)
|
|
51
|
+
|
|
52
|
+
Invoke **`/kickoff`** and **answer its interview yourself from the plan header** so it runs
|
|
53
|
+
unattended (same pattern as `plan-ticket` Step 4):
|
|
54
|
+
|
|
55
|
+
- **Framework / Layer / Repos / Client** — read directly from the plan's `**Repos:** …` header.
|
|
56
|
+
Map repos to framework (`worker/library/toga/…` → 1.0; `worker2/api2/_underscore/` + React
|
|
57
|
+
SPAs → 2.0) and layer (PHP API/worker → back-end; React SPA → front-end; both → hybrid).
|
|
58
|
+
|
|
59
|
+
Pass them as the trailing argument so kickoff goes straight to preflight + priming:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
/kickoff <framework> <layer>, repos: <repos>, client: <client> — execute <TICKET> <title>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Carry the `context-primer` briefing (framework rules, gotchas, client variations) into Step 3 —
|
|
66
|
+
it directly informs how each phase is implemented. Only stop to ask the developer if a kickoff
|
|
67
|
+
gate answer is genuinely ambiguous after reading the plan header.
|
|
68
|
+
|
|
69
|
+
## Step 3 — Execute the plan, phase by phase
|
|
70
|
+
|
|
71
|
+
Implement the plan against the **real repo paths** (resolve each from the `repo-path-<repo>`
|
|
72
|
+
memory kickoff established; never guess paths). For non-trivial work drive it as implement →
|
|
73
|
+
verify with subagents so the conversation stays the conductor:
|
|
74
|
+
|
|
75
|
+
1. For each **phase** in the plan, an implementer subagent writes that phase's code at the
|
|
76
|
+
exact file paths the plan names. When phases span **independent repos**, give each
|
|
77
|
+
implementer **`isolation: worktree`** so they cannot collide on the same checkout.
|
|
78
|
+
2. **Do NOT spawn the `php-reviewer` agent — the developer reviews PHP themselves on the PR.**
|
|
79
|
+
Still lint every PHP file with the right binary (`php -l`; 1.0 repos are PHP 7.2 →
|
|
80
|
+
`C:\xampp7\php\php.exe -l`), and still run `sql-reviewer` on files containing SQL and
|
|
81
|
+
`security-reviewer` only when genuinely warranted (raw SQL from untrusted input, outbound
|
|
82
|
+
writes, auth/credential handling). For ordinary PHP correctness/style, lint + the team rules
|
|
83
|
+
below are the bar — no PHP reviewer subagent.
|
|
84
|
+
3. Honor the team rules throughout: parameterized queries only, no bare catch / `@`, named
|
|
85
|
+
constants over magic numbers, return-type declarations, no commented-out code, no secrets or
|
|
86
|
+
absolute local paths in any file that will be committed.
|
|
87
|
+
|
|
88
|
+
> **ALL SQL MUST FOLLOW THE TEAM SQL FORMATTING CONVENTIONS — NO EXCEPTIONS, INCLUDING ONE-LINE
|
|
89
|
+
> OR THROWAWAY QUERIES.** EVERY query (even a single-clause `DELETE`/`SELECT`, even inside a raw
|
|
90
|
+
> `_Query`) IS WRITTEN MULTI-LINE: KEYWORDS UPPERCASE, EACH CLAUSE ON ITS OWN LINE, THE TABLE AND
|
|
91
|
+
> EACH COLUMN INDENTED ON ITS OWN LINE, NO `SELECT *`. NEVER EMIT A COLLAPSED ONE-LINER LIKE
|
|
92
|
+
> `new _Query('DELETE FROM X WHERE id = ' . $id, ...)`. RUN `sql-reviewer` ON EVERY FILE THAT
|
|
93
|
+
> CONTAINS SQL AND FIX FORMATTING BEFORE COMMITTING.
|
|
94
|
+
|
|
95
|
+
Do **not** push anything to a remote or open a PR until the code is implemented and clean.
|
|
96
|
+
|
|
97
|
+
## Step 4 — Per repo: branch (bare ticket id), commit, push
|
|
98
|
+
|
|
99
|
+
For **each repo in the plan's `Repos:` header** that has changes, run inside that repo's path:
|
|
100
|
+
|
|
101
|
+
1. **Branch — bare ticket id, based on the CURRENT remote default.** This matches the team
|
|
102
|
+
convention (existing branches are `TRUE-79142`, `TRUE-77219`) and is exactly what ClickUp's
|
|
103
|
+
GitHub integration auto-links on. **The branch MUST be cut from freshly-fetched
|
|
104
|
+
`origin/<default>` — never from whatever local branch the repo happens to be sitting on.** A
|
|
105
|
+
working checkout is frequently parked on a *different, unmerged ticket's branch* (or a stale
|
|
106
|
+
local default); branching off that silently bases your PR on someone else's unmerged work and
|
|
107
|
+
produces a wrong, conflict-prone diff. So always **fetch first, then branch off the remote
|
|
108
|
+
default**, carrying your uncommitted Step 3 changes onto the new branch:
|
|
109
|
+
```bash
|
|
110
|
+
# Resolve the repo's REAL default branch (e.g. _production for app repos, _main for dbchanges2)
|
|
111
|
+
DEFAULT=$(gh repo view <owner/repo> --json defaultBranchRef -q .defaultBranchRef.name)
|
|
112
|
+
git -C "<repo-path>" fetch origin --quiet
|
|
113
|
+
# Reuse an existing TRUE-XXXX branch if present; otherwise cut a fresh one from origin/<default>.
|
|
114
|
+
git -C "<repo-path>" rev-parse --verify TRUE-XXXX 2>/dev/null \
|
|
115
|
+
&& git -C "<repo-path>" checkout TRUE-XXXX \
|
|
116
|
+
|| git -C "<repo-path>" checkout -b TRUE-XXXX "origin/$DEFAULT"
|
|
117
|
+
```
|
|
118
|
+
`git checkout -b … origin/$DEFAULT` keeps your uncommitted working-tree edits and re-bases them
|
|
119
|
+
onto the latest remote default in one step. **First confirm `git status` shows only the files
|
|
120
|
+
your plan changed** (no stray edits from a prior ticket) before branching. If the checkout is
|
|
121
|
+
refused because a file you edited also changed on the default, `git stash` → `checkout -b … origin/$DEFAULT`
|
|
122
|
+
→ `git stash pop` and resolve. **Never branch off / commit to `_main`/`_production` directly**,
|
|
123
|
+
and **never force-push** (per `git-workflow.md`). If already on the `TRUE-XXXX` branch, still
|
|
124
|
+
`git fetch` and consider rebasing onto `origin/<default>` so the PR diff stays current.
|
|
125
|
+
2. **Commit.** Stage only the files the plan changed. Message format `type: short description`
|
|
126
|
+
(`feat`/`fix`/`refactor`/`docs`/`test`/`chore`, lowercase, present tense, ≤72 chars). Add a
|
|
127
|
+
body paragraph for substantial changes. **Pre-commit check:** `php -l` passes on changed PHP,
|
|
128
|
+
no commented-out code, no credentials or local paths, branch name correct.
|
|
129
|
+
3. **Push** the branch to origin: `git -C "<repo-path>" push -u origin <TICKET>`.
|
|
130
|
+
|
|
131
|
+
Repeat for every in-scope repo — one branch per repo, all named the same bare ticket id.
|
|
132
|
+
|
|
133
|
+
## Step 5 — Open a PR per repo (auto-links to the ClickUp GitHub tab)
|
|
134
|
+
|
|
135
|
+
For each pushed repo, open a PR with `gh` from that repo's path:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
gh pr create --repo <owner/repo> --base <DEFAULT-BRANCH> --head <TICKET> \
|
|
139
|
+
--title "<TICKET>" \
|
|
140
|
+
--body "<body>"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- **Title:** the **BARE TICKET ID ONLY** — e.g. `TRUE-79868`, nothing else. NO `[<repo>]` prefix,
|
|
144
|
+
NO description after it. The title must equal the branch name exactly (both the bare ticket id);
|
|
145
|
+
this is what ClickUp's GitHub integration matches on. (This intentionally overrides the
|
|
146
|
+
`[<repo>] <desc>` PR-title rule in `git-workflow.md` for ticket PRs.)
|
|
147
|
+
- **Base branch:** resolve the repo's REAL default with
|
|
148
|
+
`gh repo view <owner/repo> --json defaultBranchRef -q .defaultBranchRef.name` — for
|
|
149
|
+
agilantsolutions app repos this is **`_production`**, NOT `_main` (`_main` is a disjoint orphan
|
|
150
|
+
and `gh pr create --base _main` fails "no history in common"). Never hardcode `_main`.
|
|
151
|
+
- **Body must include the ticket id** — both the ticket url from Step 1's `url` AND the
|
|
152
|
+
**internal ClickUp id in `CU-<internalId>` form** (e.g. `CU-868k4mkku`). The internal id is the
|
|
153
|
+
`id` field from Step 1's `clickup.js get` JSON; ClickUp's GitHub integration matches the
|
|
154
|
+
internal `CU-<id>` token by default, whereas the bare custom id (`TRUE-79869`) only matches if
|
|
155
|
+
"allow custom task IDs in integrations" is enabled in the workspace. Include both. Also include,
|
|
156
|
+
per the PR-checklist rule: which repos/features are affected, how to test, and any migration
|
|
157
|
+
steps (DB schema, config, queue registration).
|
|
158
|
+
- End the PR body with the standard footer:
|
|
159
|
+
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
|
160
|
+
|
|
161
|
+
### Step 5b — Post the PR links as a ClickUp comment (the RELIABLE link — do NOT skip)
|
|
162
|
+
|
|
163
|
+
**The native GitHub↔ClickUp tab is NOT reliable in this workspace.** It only populates if each
|
|
164
|
+
repo is connected under ClickUp → Settings → Integrations → GitHub (a one-time workspace OAuth
|
|
165
|
+
setup that cannot be done from `gh`/code). When a repo is not connected, the bare-id branch +
|
|
166
|
+
title + `CU-<id>` body produce **nothing** in the ticket's GitHub tab — this is exactly what
|
|
167
|
+
happened to TRUE-79868/PR #89 and TRUE-79869/PRs #90+#639. Do not promise the GitHub tab will
|
|
168
|
+
populate; treat it as best-effort that depends on a setting outside this skill.
|
|
169
|
+
|
|
170
|
+
So **always post a comment** on the ticket with the PR links — this is the guaranteed-visible
|
|
171
|
+
link. Use the ClickUp v2 API directly (the `clickup.js` helper only does `get`/`push`):
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
node -e 'const k=process.env.CLICKUP_API_KEY,t=process.env.CLICKUP_TEAM_ID;
|
|
175
|
+
const body="PRs for <TICKET> (<title>):\n• <repo>: <pr-url>\n• ...";
|
|
176
|
+
fetch("https://api.clickup.com/api/v2/task/<TICKET>/comment?custom_task_ids=true&team_id="+t,
|
|
177
|
+
{method:"POST",headers:{Authorization:k,"Content-Type":"application/json"},
|
|
178
|
+
body:JSON.stringify({comment_text:body,notify_all:false})}).then(r=>r.text()).then(console.log)'
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
(A comment does NOT populate the GitHub *tab* — it lands in the ticket's activity/comments —
|
|
182
|
+
but it is the dependable way for the developer to reach the PRs from the ticket.)
|
|
183
|
+
|
|
184
|
+
## Step 6 — Report & stop (do NOT touch ClickUp status)
|
|
185
|
+
|
|
186
|
+
Summarize for the developer:
|
|
187
|
+
- Ticket title and the repos that got changes.
|
|
188
|
+
- For each repo: the branch name (`<TICKET>`) and the **PR url** `gh` returned.
|
|
189
|
+
- Confirm the Step 5b comment was posted (the reliable link). State plainly that the GitHub
|
|
190
|
+
**tab** populates only if the repos are connected in ClickUp's GitHub integration settings —
|
|
191
|
+
do not claim it will appear automatically.
|
|
192
|
+
- **Remind the developer the next step is theirs:** review/approve the PR(s), then **manually
|
|
193
|
+
move the ClickUp ticket forward** for approval — this skill intentionally does not change
|
|
194
|
+
ticket status.
|
|
195
|
+
|
|
196
|
+
Offer `/capture` if a durable, KB-worthy finding emerged while implementing.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Notes
|
|
201
|
+
|
|
202
|
+
- **Source of truth is the Pseudocode field** (Step 1), because the local approach file may not
|
|
203
|
+
exist on the executing machine. If both exist and disagree, the field wins.
|
|
204
|
+
- **Branch = bare ticket id**, one per repo (multi-repo plans get one branch + one PR each, all
|
|
205
|
+
linked to the same ticket). This intentionally overrides the generic `feature/<desc>` prefix
|
|
206
|
+
rule in `git-workflow.md` to match the team's actual convention.
|
|
207
|
+
- **Linking is NOT automatic.** The native GitHub tab requires a per-repo connection in ClickUp's
|
|
208
|
+
GitHub integration settings (workspace OAuth, not doable from `gh`). The branch name + bare-id
|
|
209
|
+
PR title + `CU-<internalId>` in the body are necessary but **not sufficient** — if the repo
|
|
210
|
+
isn't connected, the tab stays empty. Step 5b's comment is the reliable link; always post it.
|
|
211
|
+
- **Autonomy boundary:** autonomous through PR creation; stops there. Moving the ClickUp ticket
|
|
212
|
+
is always a manual developer action.
|
|
213
|
+
- Run AFTER `/plan-ticket` has produced and the developer has approved a plan. This skill does
|
|
214
|
+
not plan — it executes an already-approved plan.
|