td-ai-tools 1.1.2 → 1.1.4
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/bin/cli.js +46 -7
- package/package.json +1 -1
- package/skills/README.md +1 -0
- package/skills/barrage/SKILL.md +180 -0
- package/skills/barrage/agents/openai.yaml +4 -0
- package/skills/barrage/scripts/__pycache__/build_queue.cpython-312.pyc +0 -0
- package/skills/barrage/scripts/build_queue.py +276 -0
- package/skills/barrage/tests/test_build_queue.py +135 -0
package/bin/cli.js
CHANGED
|
@@ -238,6 +238,18 @@ function buildDeleteMenu() {
|
|
|
238
238
|
return [...skills, ...agents];
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
function buildUpdateMenu() {
|
|
242
|
+
const availableSkills = new Set(getAvailable(SKILLS_DIR));
|
|
243
|
+
const availableAgents = new Set(getAvailable(AGENTS_DIR));
|
|
244
|
+
const skills = getInstalled('skills')
|
|
245
|
+
.filter(name => availableSkills.has(name))
|
|
246
|
+
.map(name => ({ type: 'skill', name }));
|
|
247
|
+
const agents = getInstalled('agents')
|
|
248
|
+
.filter(name => availableAgents.has(name))
|
|
249
|
+
.map(name => ({ type: 'agent', name }));
|
|
250
|
+
return [...skills, ...agents];
|
|
251
|
+
}
|
|
252
|
+
|
|
241
253
|
function toGroupOptions(menu, { hintForInstalled = false } = {}) {
|
|
242
254
|
const skillItems = menu.filter(i => i.type === 'skill').map(i => {
|
|
243
255
|
const hint = hintForInstalled ? '' : getSkillHint(i.name);
|
|
@@ -275,9 +287,11 @@ function deleteItems(items) {
|
|
|
275
287
|
}
|
|
276
288
|
|
|
277
289
|
async function interactiveInstall(mode = 'install') {
|
|
278
|
-
const menu = buildMenu();
|
|
290
|
+
const menu = mode === 'update' ? buildUpdateMenu() : buildMenu();
|
|
279
291
|
if (menu.length === 0) {
|
|
280
|
-
status('info',
|
|
292
|
+
status('info', mode === 'update'
|
|
293
|
+
? 'No installed skills or agent packs match the catalog.'
|
|
294
|
+
: 'No skills or agent packs available.');
|
|
281
295
|
return;
|
|
282
296
|
}
|
|
283
297
|
if (!IS_TTY) {
|
|
@@ -369,6 +383,26 @@ function resolveNames(names) {
|
|
|
369
383
|
return items;
|
|
370
384
|
}
|
|
371
385
|
|
|
386
|
+
function resolveUpdateNames(names) {
|
|
387
|
+
const availableSkills = new Set(getAvailable(SKILLS_DIR));
|
|
388
|
+
const availableAgents = new Set(getAvailable(AGENTS_DIR));
|
|
389
|
+
const installedSkills = getInstalled('skills');
|
|
390
|
+
const installedAgents = getInstalled('agents');
|
|
391
|
+
const items = [];
|
|
392
|
+
for (const name of names) {
|
|
393
|
+
if (installedSkills.includes(name) && availableSkills.has(name)) {
|
|
394
|
+
items.push({ type: 'skill', name });
|
|
395
|
+
} else if (installedAgents.includes(name) && availableAgents.has(name)) {
|
|
396
|
+
items.push({ type: 'agent', name });
|
|
397
|
+
} else if (!installedSkills.includes(name) && !installedAgents.includes(name)) {
|
|
398
|
+
status('error', `"${name}" is not installed.`);
|
|
399
|
+
} else {
|
|
400
|
+
status('error', `"${name}" is installed but not in the catalog.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return items;
|
|
404
|
+
}
|
|
405
|
+
|
|
372
406
|
function resolveDeleteNames(names) {
|
|
373
407
|
const installedSkills = getInstalled('skills');
|
|
374
408
|
const installedAgents = getInstalled('agents');
|
|
@@ -391,9 +425,9 @@ const HELP_TEXT = `Usage:
|
|
|
391
425
|
npx td-ai-tools install Interactive install
|
|
392
426
|
npx td-ai-tools install --all Install everything
|
|
393
427
|
npx td-ai-tools install <name...> Install specific skills or agent packs
|
|
394
|
-
npx td-ai-tools update Interactive update (
|
|
395
|
-
npx td-ai-tools update --all Update
|
|
396
|
-
npx td-ai-tools update <name...> Update specific skills or agent packs
|
|
428
|
+
npx td-ai-tools update Interactive update (installed items in the catalog)
|
|
429
|
+
npx td-ai-tools update --all Update all installed items found in the catalog
|
|
430
|
+
npx td-ai-tools update <name...> Update specific installed skills or agent packs
|
|
397
431
|
npx td-ai-tools delete Interactive delete
|
|
398
432
|
npx td-ai-tools delete --all Delete everything
|
|
399
433
|
npx td-ai-tools delete <name...> Delete specific skills or agent packs
|
|
@@ -450,9 +484,14 @@ async function main() {
|
|
|
450
484
|
return;
|
|
451
485
|
}
|
|
452
486
|
if (rest[0] === '--all') {
|
|
453
|
-
|
|
487
|
+
const menu = buildUpdateMenu();
|
|
488
|
+
if (menu.length === 0) {
|
|
489
|
+
status('info', 'No installed skills or agent packs match the catalog.');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
installItems(menu, { replaceExisting: true });
|
|
454
493
|
} else {
|
|
455
|
-
installItems(
|
|
494
|
+
installItems(resolveUpdateNames(rest), { replaceExisting: true });
|
|
456
495
|
}
|
|
457
496
|
return;
|
|
458
497
|
}
|
package/package.json
CHANGED
package/skills/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# skills
|
|
2
2
|
|
|
3
3
|
## Available Skills
|
|
4
|
+
- `barrage`: Convert a group of Basecamp todos or cards into a td-barrage queue.json task file.
|
|
4
5
|
- `cache-reset`: Clear and warm Laravel and Statamic caches after content/template changes.
|
|
5
6
|
- `horizon-component-migration`: Bundle Horizon components and recursive dependencies into a migration package for another theme.
|
|
6
7
|
- `pr-solver`: Resolve unresolved GitHub PR review threads with GraphQL-driven workflow.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: barrage
|
|
3
|
+
description: |
|
|
4
|
+
Convert a group of Basecamp todos or cards into a td-barrage queue.json task
|
|
5
|
+
file. Fetches the items via the Basecamp CLI, maps each title -> task id and
|
|
6
|
+
description -> prompt, and writes queue.json into this skill's directory. Use
|
|
7
|
+
when the user wants to turn Basecamp work into a barrage queue, build a
|
|
8
|
+
td-barrage queue from Basecamp, or run a todolist/card table through td-barrage.
|
|
9
|
+
invocable: true
|
|
10
|
+
argument-hint: "[basecamp todolist/card-table/column URL] [--timeout-mins N]"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# /barrage — Basecamp → td-barrage queue builder
|
|
14
|
+
|
|
15
|
+
Turns a user-specified group of Basecamp todos or cards into a `queue.json` in the
|
|
16
|
+
format consumed by **td-barrage**, a batch task runner (see
|
|
17
|
+
[Output: the td-barrage queue.json format](#output-the-td-barrage-queuejson-format)
|
|
18
|
+
below for the full schema — this skill emits that format directly and needs no
|
|
19
|
+
other repo installed).
|
|
20
|
+
|
|
21
|
+
## Input: the expected Basecamp item shape
|
|
22
|
+
|
|
23
|
+
This skill is designed for Basecamp items whose **titles carry a trailing `[Nh]`
|
|
24
|
+
estimate suffix** — e.g. `Build the checkout page [2h]` or `Fix nav [1.5h]`. That
|
|
25
|
+
convention comes from an estimation workflow (the **td-augury** generator produces
|
|
26
|
+
titles in exactly this shape), but nothing here requires that tool: any Basecamp
|
|
27
|
+
todo or card works. Titles *without* a suffix are used verbatim; titles *with* one
|
|
28
|
+
have it stripped from the task id by default (see `--keep-estimate`).
|
|
29
|
+
|
|
30
|
+
Concretely, each item is read from the Basecamp CLI's JSON output, which exposes:
|
|
31
|
+
|
|
32
|
+
- `title` — the display title (todos and cards)
|
|
33
|
+
- `content` — for todos this equals the title; for cards it holds the card body (HTML)
|
|
34
|
+
- `description` — the long-form body when present (preferred for the prompt)
|
|
35
|
+
|
|
36
|
+
## What it produces
|
|
37
|
+
|
|
38
|
+
Each Basecamp item becomes one td-barrage task:
|
|
39
|
+
|
|
40
|
+
| td-barrage field | Source | Notes |
|
|
41
|
+
|------------------|--------|-------|
|
|
42
|
+
| `id` | item **title** | trailing `[Nh]` estimate suffix stripped; deduped if needed |
|
|
43
|
+
| `prompt` | item **description** | HTML flattened to plain text |
|
|
44
|
+
| `success` | constant | always `{ "strategy": "default" }` |
|
|
45
|
+
| `timeoutMs` | constant | `35` minutes → `2100000` ms, unless the user specifies otherwise |
|
|
46
|
+
| `maxAttempts` | constant | `2` |
|
|
47
|
+
|
|
48
|
+
> The user may refer to these as `barrage_id`, `success_criteria`, `timeout`, and
|
|
49
|
+
> `max_attempts`. The actual td-barrage schema uses `id`, `success.strategy`,
|
|
50
|
+
> `timeoutMs` (**milliseconds**), and `maxAttempts` — this skill emits the real
|
|
51
|
+
> field names (documented in full below).
|
|
52
|
+
|
|
53
|
+
The file is written to **this skill's directory** as `queue.json`
|
|
54
|
+
(`build_queue.py` resolves that path from its own location, so it lands next to the
|
|
55
|
+
script regardless of which skills copy is invoked).
|
|
56
|
+
|
|
57
|
+
## Workflow
|
|
58
|
+
|
|
59
|
+
### 1. Determine the group and item type
|
|
60
|
+
|
|
61
|
+
Ask the user (if not already clear) for the Basecamp group to convert and whether
|
|
62
|
+
it is **todos** or **cards**. A "group" is normally one of:
|
|
63
|
+
|
|
64
|
+
- a **todolist** URL → its todos
|
|
65
|
+
- a **card table** URL → all its cards
|
|
66
|
+
- a **column** URL → the cards in that column
|
|
67
|
+
- a whole **project** → all todos or all cards in it
|
|
68
|
+
|
|
69
|
+
### 2. Resolve IDs from the URL
|
|
70
|
+
|
|
71
|
+
Always parse a URL first to extract the project and recording IDs:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
basecamp url parse "<url>" --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This returns `project_id`, `type`, and `recording_id`.
|
|
78
|
+
|
|
79
|
+
### 3. Fetch the items as JSON
|
|
80
|
+
|
|
81
|
+
Use the matching list command. **Always pass `--json`.**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Todos in a specific todolist
|
|
85
|
+
basecamp todos list --list <todolist_id> --in <project_id> --json > items.json
|
|
86
|
+
|
|
87
|
+
# All todos in a project
|
|
88
|
+
basecamp todos list --in <project_id> --json > items.json
|
|
89
|
+
|
|
90
|
+
# Cards in a specific card table (required flag if a project has >1 table)
|
|
91
|
+
basecamp cards list --card-table <table_id> --in <project_id> --json > items.json
|
|
92
|
+
|
|
93
|
+
# Cards in a single column
|
|
94
|
+
basecamp cards list --column <column_id> --in <project_id> --json > items.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The CLI returns an envelope `{ ok, data, ... }`; the converter reads `data`
|
|
98
|
+
automatically (it also accepts a bare array or `--agent` raw output). By default
|
|
99
|
+
all (non-completed) items are returned — add `--all` for large lists, or filter
|
|
100
|
+
(e.g. `--status`) if the user only wants some.
|
|
101
|
+
|
|
102
|
+
### 4. Convert to queue.json
|
|
103
|
+
|
|
104
|
+
Pipe the fetched JSON into the converter (or pass `--input`):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python3 "$SKILL_DIR/scripts/build_queue.py" --input items.json
|
|
108
|
+
# or
|
|
109
|
+
basecamp todos list --list <id> --in <proj> --json \
|
|
110
|
+
| python3 "$SKILL_DIR/scripts/build_queue.py"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`$SKILL_DIR` is the directory containing this `SKILL.md`. The script writes
|
|
114
|
+
`queue.json` into that directory and prints a summary plus any per-item warnings
|
|
115
|
+
(skipped items with no description, deduped ids, etc.).
|
|
116
|
+
|
|
117
|
+
Options:
|
|
118
|
+
|
|
119
|
+
- `--timeout-mins N` — override the 35-minute default (the script converts to ms).
|
|
120
|
+
- `--max-attempts N` — override the default of 2.
|
|
121
|
+
- `--keep-estimate` — keep the `[Nh]` suffix in the id instead of stripping it.
|
|
122
|
+
- `--output PATH` — write somewhere other than `<skill>/queue.json`.
|
|
123
|
+
|
|
124
|
+
### 5. Report
|
|
125
|
+
|
|
126
|
+
Tell the user where `queue.json` was written, how many tasks it contains, and
|
|
127
|
+
surface any warnings the script printed (skipped or renamed items).
|
|
128
|
+
|
|
129
|
+
## Output: the td-barrage queue.json format
|
|
130
|
+
|
|
131
|
+
td-barrage validates the file with a Zod schema. This skill emits the minimal
|
|
132
|
+
valid subset; the runner fills in the rest with the defaults shown below. The full
|
|
133
|
+
task schema is:
|
|
134
|
+
|
|
135
|
+
```jsonc
|
|
136
|
+
{
|
|
137
|
+
"tasks": [
|
|
138
|
+
{
|
|
139
|
+
"id": "string", // required, non-empty; must be unique across the file
|
|
140
|
+
"prompt": "string", // required, non-empty
|
|
141
|
+
"dependsOn": ["string"], // optional, default [] (ids of prerequisite tasks)
|
|
142
|
+
"maxAttempts": 2, // optional, integer >= 1
|
|
143
|
+
"timeoutMs": 2100000, // optional, positive integer (MILLISECONDS)
|
|
144
|
+
"success": { // optional, default { "strategy": "default" }
|
|
145
|
+
"strategy": "default" // one of: "default" | "file_exists" | "result_json"
|
|
146
|
+
// "file_exists" also needs: "path": "string"
|
|
147
|
+
// "result_json" also needs: "path": "string"
|
|
148
|
+
},
|
|
149
|
+
"cwd": "string", // optional, working directory for the task
|
|
150
|
+
"resultPath": "string" // optional, where the task writes its result
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The top-level value may be either `{ "tasks": [...] }` (what this skill writes) or a
|
|
157
|
+
bare `[...]` array of tasks. Runtime-managed fields (`status`, `attempts`, `error`,
|
|
158
|
+
`startedAt`, `finishedAt`, `recoveryNotes`) are set by td-barrage and should not be
|
|
159
|
+
authored here.
|
|
160
|
+
|
|
161
|
+
Validation rules enforced by the runner:
|
|
162
|
+
|
|
163
|
+
- **Unique ids** — duplicate `id` values are rejected (`Duplicate task id`). The
|
|
164
|
+
converter pre-empts this by renaming collisions to `"<title> (2)"`.
|
|
165
|
+
- **No self-dependency** — a task cannot list its own id in `dependsOn`.
|
|
166
|
+
- **No dangling dependencies** — every id in `dependsOn` must match another task's id.
|
|
167
|
+
|
|
168
|
+
## Notes & caveats
|
|
169
|
+
|
|
170
|
+
- **Empty descriptions are skipped.** td-barrage requires a non-empty `prompt`, so
|
|
171
|
+
items with no description cannot become tasks. The script warns for each one —
|
|
172
|
+
relay this so the user can add descriptions in Basecamp and re-run if needed.
|
|
173
|
+
- **Duplicate titles** would make td-barrage reject the whole file
|
|
174
|
+
(`Duplicate task id`). The script renames collisions to `"<title> (2)"` and
|
|
175
|
+
warns; prefer fixing the source titles when that happens.
|
|
176
|
+
- **No dependencies are emitted.** Every task is independent (`dependsOn` defaults
|
|
177
|
+
to `[]`). If the user wants an execution order, edit `queue.json` afterward to add
|
|
178
|
+
`dependsOn` arrays referencing other task ids.
|
|
179
|
+
- The Basecamp CLI is already authenticated; verify with
|
|
180
|
+
`basecamp auth status --json` if a fetch fails.
|
|
Binary file
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Convert Basecamp todos/cards into a td-barrage queue.json.
|
|
3
|
+
|
|
4
|
+
Reads the JSON emitted by the Basecamp CLI (`basecamp todos list ... --json`,
|
|
5
|
+
`basecamp cards list ... --json`, or their `--agent` raw form) and writes a
|
|
6
|
+
td-barrage task file. The only runtime dependency is the Basecamp CLI that
|
|
7
|
+
produced the input; the queue.json format it writes is documented in ../SKILL.md
|
|
8
|
+
("Output: the td-barrage queue.json format") so no other repo need be installed.
|
|
9
|
+
|
|
10
|
+
Field mapping (see ../SKILL.md):
|
|
11
|
+
- id <- the item title, with any trailing `[Nh]` estimate suffix stripped
|
|
12
|
+
- prompt <- the item description (HTML stripped to plain text)
|
|
13
|
+
- success <- {"strategy": "default"} on every item
|
|
14
|
+
- timeoutMs <- --timeout-mins * 60 * 1000 (default 35 minutes)
|
|
15
|
+
- maxAttempts <- --max-attempts (default 2)
|
|
16
|
+
|
|
17
|
+
The Basecamp CLI normalizes both todos and cards to expose `title`, `content`,
|
|
18
|
+
and `description`. For todos `content == title`; for cards the body lives in
|
|
19
|
+
`content` (HTML). We therefore prefer `description` for the prompt and only fall
|
|
20
|
+
back to `content` when it differs from the title.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from html.parser import HTMLParser
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
# Skill root is the parent of this script's directory (scripts/ -> <skill>/).
|
|
34
|
+
SKILL_DIR = Path(__file__).resolve().parent.parent
|
|
35
|
+
|
|
36
|
+
# Matches a trailing estimate suffix like " [2h]" or " [1.5h]" (case-insensitive).
|
|
37
|
+
# Basecamp titles produced by an estimation workflow carry this suffix (the
|
|
38
|
+
# td-augury generator appends "[<hours>h]"); we strip it from the task id so the
|
|
39
|
+
# id reads as a plain title. Titles without a suffix are unaffected.
|
|
40
|
+
ESTIMATE_SUFFIX_RE = re.compile(r"\s*\[\d+(?:\.\d+)?h\]\s*$", re.IGNORECASE)
|
|
41
|
+
|
|
42
|
+
# Block-level tags whose close (or self-close) should become a line break when
|
|
43
|
+
# flattening an HTML card body to plain text.
|
|
44
|
+
_BLOCK_TAGS = {
|
|
45
|
+
"p", "div", "br", "li", "ul", "ol", "tr", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
46
|
+
"blockquote", "pre", "section", "article", "header", "footer",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _TextExtractor(HTMLParser):
|
|
51
|
+
"""Collapse an HTML fragment to readable plain text, preserving line breaks."""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
super().__init__(convert_charrefs=True)
|
|
55
|
+
self._parts: list[str] = []
|
|
56
|
+
|
|
57
|
+
def handle_data(self, data: str) -> None:
|
|
58
|
+
self._parts.append(data)
|
|
59
|
+
|
|
60
|
+
def handle_starttag(self, tag: str, attrs: Any) -> None:
|
|
61
|
+
if tag == "br":
|
|
62
|
+
self._parts.append("\n")
|
|
63
|
+
|
|
64
|
+
def handle_endtag(self, tag: str) -> None:
|
|
65
|
+
if tag in _BLOCK_TAGS:
|
|
66
|
+
self._parts.append("\n")
|
|
67
|
+
|
|
68
|
+
def text(self) -> str:
|
|
69
|
+
return "".join(self._parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def html_to_text(value: str) -> str:
|
|
73
|
+
"""Strip HTML tags/entities and normalize whitespace.
|
|
74
|
+
|
|
75
|
+
Plain-text input passes through unchanged apart from whitespace cleanup, so
|
|
76
|
+
it is safe to run on both todo descriptions (plain) and card bodies (HTML).
|
|
77
|
+
"""
|
|
78
|
+
parser = _TextExtractor()
|
|
79
|
+
parser.feed(value)
|
|
80
|
+
text = parser.text()
|
|
81
|
+
# Collapse runs of blank lines and trim trailing spaces on each line.
|
|
82
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
83
|
+
out: list[str] = []
|
|
84
|
+
blank = False
|
|
85
|
+
for line in lines:
|
|
86
|
+
if line:
|
|
87
|
+
out.append(line)
|
|
88
|
+
blank = False
|
|
89
|
+
elif not blank and out:
|
|
90
|
+
out.append("")
|
|
91
|
+
blank = True
|
|
92
|
+
return "\n".join(out).strip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def first_nonempty(record: dict[str, Any], keys: tuple[str, ...]) -> str:
|
|
96
|
+
for key in keys:
|
|
97
|
+
value = record.get(key)
|
|
98
|
+
if isinstance(value, str) and value.strip():
|
|
99
|
+
return value.strip()
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_title(record: dict[str, Any]) -> str:
|
|
104
|
+
"""The display title (before estimate-suffix stripping)."""
|
|
105
|
+
return first_nonempty(record, ("title", "content", "name"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def extract_description(record: dict[str, Any], title: str) -> str:
|
|
109
|
+
"""The prompt body, HTML-flattened.
|
|
110
|
+
|
|
111
|
+
Prefer the dedicated `description` field. Fall back to `content` only when it
|
|
112
|
+
differs from the title, so a todo (where content == title) does not get its
|
|
113
|
+
own title echoed back as the prompt.
|
|
114
|
+
"""
|
|
115
|
+
desc = first_nonempty(record, ("description",))
|
|
116
|
+
if not desc:
|
|
117
|
+
content = first_nonempty(record, ("content",))
|
|
118
|
+
if content and content != title:
|
|
119
|
+
desc = content
|
|
120
|
+
return html_to_text(desc)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def make_id(title: str, keep_estimate: bool) -> str:
|
|
124
|
+
if keep_estimate:
|
|
125
|
+
return title
|
|
126
|
+
return ESTIMATE_SUFFIX_RE.sub("", title).strip()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def unwrap_items(payload: Any) -> list[dict[str, Any]]:
|
|
130
|
+
"""Accept the CLI envelope, the --agent raw form, a bare array, or one item."""
|
|
131
|
+
if isinstance(payload, dict):
|
|
132
|
+
if "data" in payload: # standard --json envelope: {ok, data, ...}
|
|
133
|
+
payload = payload["data"]
|
|
134
|
+
elif "tasks" in payload: # already a queue file
|
|
135
|
+
payload = payload["tasks"]
|
|
136
|
+
else: # a single recording object
|
|
137
|
+
return [payload]
|
|
138
|
+
if isinstance(payload, list):
|
|
139
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
140
|
+
raise SystemExit("Unrecognized input JSON: expected an envelope, array, or object.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_tasks(
|
|
144
|
+
items: list[dict[str, Any]],
|
|
145
|
+
*,
|
|
146
|
+
timeout_ms: int,
|
|
147
|
+
max_attempts: int,
|
|
148
|
+
keep_estimate: bool,
|
|
149
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
150
|
+
tasks: list[dict[str, Any]] = []
|
|
151
|
+
warnings: list[str] = []
|
|
152
|
+
seen: dict[str, int] = {}
|
|
153
|
+
|
|
154
|
+
for index, record in enumerate(items):
|
|
155
|
+
title = extract_title(record)
|
|
156
|
+
if not title:
|
|
157
|
+
warnings.append(f"Item #{index} (id={record.get('id')}): no title; skipped.")
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
prompt = extract_description(record, title)
|
|
161
|
+
if not prompt:
|
|
162
|
+
warnings.append(
|
|
163
|
+
f"Item #{index} {title!r}: empty description; skipped "
|
|
164
|
+
"(td-barrage requires a non-empty prompt)."
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
task_id = make_id(title, keep_estimate)
|
|
169
|
+
if not task_id:
|
|
170
|
+
task_id = title # estimate-only title edge case
|
|
171
|
+
|
|
172
|
+
# De-duplicate ids so the file stays loadable (td-barrage rejects dupes).
|
|
173
|
+
if task_id in seen:
|
|
174
|
+
seen[task_id] += 1
|
|
175
|
+
deduped = f"{task_id} ({seen[task_id]})"
|
|
176
|
+
warnings.append(f"Duplicate id {task_id!r}; renamed to {deduped!r}.")
|
|
177
|
+
task_id = deduped
|
|
178
|
+
else:
|
|
179
|
+
seen[task_id] = 1
|
|
180
|
+
|
|
181
|
+
tasks.append(
|
|
182
|
+
{
|
|
183
|
+
"id": task_id,
|
|
184
|
+
"prompt": prompt,
|
|
185
|
+
"timeoutMs": timeout_ms,
|
|
186
|
+
"maxAttempts": max_attempts,
|
|
187
|
+
"success": {"strategy": "default"},
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return tasks, warnings
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
195
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--input",
|
|
198
|
+
type=Path,
|
|
199
|
+
help="Path to the Basecamp CLI JSON. Reads stdin if omitted.",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--output",
|
|
203
|
+
type=Path,
|
|
204
|
+
default=SKILL_DIR / "queue.json",
|
|
205
|
+
help="Where to write the queue file (default: <skill>/queue.json).",
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
"--timeout-mins",
|
|
209
|
+
type=float,
|
|
210
|
+
default=35.0,
|
|
211
|
+
help="Per-task timeout in minutes (default: 35).",
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
"--max-attempts",
|
|
215
|
+
type=int,
|
|
216
|
+
default=2,
|
|
217
|
+
help="Per-task max attempts (default: 2).",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"--keep-estimate",
|
|
221
|
+
action="store_true",
|
|
222
|
+
help="Keep the trailing [Nh] estimate suffix in the id (stripped by default).",
|
|
223
|
+
)
|
|
224
|
+
return parser.parse_args(argv)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv: list[str] | None = None) -> int:
|
|
228
|
+
args = parse_args(argv)
|
|
229
|
+
|
|
230
|
+
raw = args.input.read_text() if args.input else sys.stdin.read()
|
|
231
|
+
if not raw.strip():
|
|
232
|
+
print("No input JSON provided.", file=sys.stderr)
|
|
233
|
+
return 1
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
payload = json.loads(raw)
|
|
237
|
+
except json.JSONDecodeError as exc:
|
|
238
|
+
print(f"Failed to parse input JSON: {exc}", file=sys.stderr)
|
|
239
|
+
return 1
|
|
240
|
+
|
|
241
|
+
if isinstance(payload, dict) and payload.get("ok") is False:
|
|
242
|
+
print(f"Basecamp CLI returned an error: {payload.get('error')}", file=sys.stderr)
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
items = unwrap_items(payload)
|
|
246
|
+
if not items:
|
|
247
|
+
print("Input contained no items.", file=sys.stderr)
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
timeout_ms = int(round(args.timeout_mins * 60 * 1000))
|
|
251
|
+
tasks, warnings = build_tasks(
|
|
252
|
+
items,
|
|
253
|
+
timeout_ms=timeout_ms,
|
|
254
|
+
max_attempts=args.max_attempts,
|
|
255
|
+
keep_estimate=args.keep_estimate,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
for warning in warnings:
|
|
259
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
260
|
+
|
|
261
|
+
if not tasks:
|
|
262
|
+
print("No usable tasks produced (every item lacked a title or description).", file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
|
|
265
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
args.output.write_text(json.dumps({"tasks": tasks}, indent=2) + "\n")
|
|
267
|
+
|
|
268
|
+
print(
|
|
269
|
+
f"Wrote {len(tasks)} task(s) to {args.output} "
|
|
270
|
+
f"(timeout={args.timeout_mins}m / {timeout_ms}ms, maxAttempts={args.max_attempts})."
|
|
271
|
+
)
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Tests for build_queue.py — verifies the Basecamp -> td-barrage mapping.
|
|
2
|
+
|
|
3
|
+
Run: python3 -m pytest .agents/skills/barrage/tests/ (or just `python3 test_build_queue.py`).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "build_queue.py"
|
|
14
|
+
_spec = importlib.util.spec_from_file_location("build_queue", _SCRIPT)
|
|
15
|
+
assert _spec and _spec.loader
|
|
16
|
+
bq = importlib.util.module_from_spec(_spec)
|
|
17
|
+
_spec.loader.exec_module(bq)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Shapes mirror what the Basecamp CLI actually returns (verified against the live
|
|
21
|
+
# CLI): todos expose title == content plus a separate description; cards carry the
|
|
22
|
+
# body in an HTML `content` field.
|
|
23
|
+
TODO = {
|
|
24
|
+
"id": 1,
|
|
25
|
+
"title": "Design — Search [2h]",
|
|
26
|
+
"content": "Design — Search [2h]",
|
|
27
|
+
"description": "Build the search UI and wire it to the API.",
|
|
28
|
+
}
|
|
29
|
+
CARD = {
|
|
30
|
+
"id": 2,
|
|
31
|
+
"title": "Implement parser [1.5h]",
|
|
32
|
+
"content": "<div>Parse the file.</div><div>Emit JSON.</div>",
|
|
33
|
+
"description": "",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build(items, **kw):
|
|
38
|
+
defaults = {"timeout_ms": 2_100_000, "max_attempts": 2, "keep_estimate": False}
|
|
39
|
+
defaults.update(kw)
|
|
40
|
+
return bq.build_tasks(items, **defaults)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_todo_mapping():
|
|
44
|
+
tasks, warnings = build([TODO])
|
|
45
|
+
assert warnings == []
|
|
46
|
+
assert tasks == [
|
|
47
|
+
{
|
|
48
|
+
"id": "Design — Search", # [2h] stripped
|
|
49
|
+
"prompt": "Build the search UI and wire it to the API.",
|
|
50
|
+
"timeoutMs": 2_100_000,
|
|
51
|
+
"maxAttempts": 2,
|
|
52
|
+
"success": {"strategy": "default"},
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_card_html_body_becomes_prompt():
|
|
58
|
+
tasks, _ = build([CARD])
|
|
59
|
+
assert tasks[0]["id"] == "Implement parser"
|
|
60
|
+
# HTML flattened, block tags -> line breaks.
|
|
61
|
+
assert tasks[0]["prompt"] == "Parse the file.\nEmit JSON."
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_default_success_timeout_attempts():
|
|
65
|
+
tasks, _ = build([TODO], timeout_ms=999, max_attempts=5)
|
|
66
|
+
assert tasks[0]["success"] == {"strategy": "default"}
|
|
67
|
+
assert tasks[0]["timeoutMs"] == 999
|
|
68
|
+
assert tasks[0]["maxAttempts"] == 5
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_keep_estimate_suffix():
|
|
72
|
+
tasks, _ = build([TODO], keep_estimate=True)
|
|
73
|
+
assert tasks[0]["id"] == "Design — Search [2h]"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_empty_description_is_skipped_with_warning():
|
|
77
|
+
item = {"id": 9, "title": "No body", "content": "No body", "description": ""}
|
|
78
|
+
tasks, warnings = build([item])
|
|
79
|
+
assert tasks == []
|
|
80
|
+
assert any("empty description" in w for w in warnings)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_todo_title_not_echoed_as_prompt():
|
|
84
|
+
# content == title and no description -> must NOT use title as the prompt.
|
|
85
|
+
item = {"id": 9, "title": "Echo", "content": "Echo", "description": ""}
|
|
86
|
+
tasks, _ = build([item])
|
|
87
|
+
assert tasks == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_duplicate_ids_are_renamed():
|
|
91
|
+
a = {"id": 1, "title": "Same", "content": "Same", "description": "first"}
|
|
92
|
+
b = {"id": 2, "title": "Same", "content": "Same", "description": "second"}
|
|
93
|
+
tasks, warnings = build([a, b])
|
|
94
|
+
ids = [t["id"] for t in tasks]
|
|
95
|
+
assert ids == ["Same", "Same (2)"]
|
|
96
|
+
assert any("Duplicate" in w for w in warnings)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_unwrap_envelope_array_and_object():
|
|
100
|
+
assert bq.unwrap_items({"ok": True, "data": [TODO]}) == [TODO]
|
|
101
|
+
assert bq.unwrap_items([TODO]) == [TODO]
|
|
102
|
+
assert bq.unwrap_items(TODO) == [TODO]
|
|
103
|
+
assert bq.unwrap_items({"tasks": [TODO]}) == [TODO]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_end_to_end_via_main(tmp_path):
|
|
107
|
+
src = tmp_path / "items.json"
|
|
108
|
+
src.write_text(json.dumps({"ok": True, "data": [TODO, CARD]}))
|
|
109
|
+
out = tmp_path / "queue.json"
|
|
110
|
+
rc = bq.main(["--input", str(src), "--output", str(out)])
|
|
111
|
+
assert rc == 0
|
|
112
|
+
data = json.loads(out.read_text())
|
|
113
|
+
assert [t["id"] for t in data["tasks"]] == ["Design — Search", "Implement parser"]
|
|
114
|
+
assert all(t["success"] == {"strategy": "default"} for t in data["tasks"])
|
|
115
|
+
assert all(t["timeoutMs"] == 2_100_000 for t in data["tasks"])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
failures = 0
|
|
120
|
+
for name, fn in sorted(globals().items()):
|
|
121
|
+
if name.startswith("test_") and callable(fn):
|
|
122
|
+
try:
|
|
123
|
+
# crude tmp_path for direct (non-pytest) runs
|
|
124
|
+
if "tmp_path" in fn.__code__.co_varnames[: fn.__code__.co_argcount]:
|
|
125
|
+
import tempfile
|
|
126
|
+
|
|
127
|
+
with tempfile.TemporaryDirectory() as d:
|
|
128
|
+
fn(Path(d))
|
|
129
|
+
else:
|
|
130
|
+
fn()
|
|
131
|
+
print(f"ok {name}")
|
|
132
|
+
except AssertionError as exc:
|
|
133
|
+
failures += 1
|
|
134
|
+
print(f"FAIL {name}: {exc}")
|
|
135
|
+
sys.exit(1 if failures else 0)
|