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 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', 'No skills or agent packs available.');
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 (replaces existing items)
395
- npx td-ai-tools update --all Update everything
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
- installItems(buildMenu(), { replaceExisting: true });
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(resolveNames(rest), { replaceExisting: true });
494
+ installItems(resolveUpdateNames(rest), { replaceExisting: true });
456
495
  }
457
496
  return;
458
497
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "type": "module",
6
6
  "scripts": {
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.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Barrage"
3
+ short_description: "Build a td-barrage queue.json from Basecamp todos or cards"
4
+ default_prompt: "Use $barrage to convert a group of Basecamp todos or cards into a td-barrage queue.json task 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)