td-ai-tools 1.3.6 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.3.6",
3
+ "version": "1.4.0",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "type": "module",
6
6
  "scripts": {
package/skills/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  ## Available Skills
4
4
  - `a11y-audit`: Run script-driven accessibility audits for user-provided URLs with Axe Core and Playwright, targeting…
5
- - `barrage`: Convert a group of Basecamp todos or cards into a td-barrage queue.json task file.
5
+ - `barrage`: Convert a group of Basecamp todos or cards into a td-barrage queue.json task file, and maintain an existing…
6
6
  - `basecamp`: Interact with Basecamp via the Basecamp CLI.
7
7
  - `browser-validation`: Before completing a task validate frontend or template changes in a real browser with the Playwright-CLI…
8
8
  - `cache-reset`: Clear and warm Laravel and Statamic caches (including Statamic Glide image caches) after content or template…
9
9
  - `client-overview`: Generate a client-facing markdown report that summarizes all changes on the current branch against the…
10
10
  - `debugging-ios-webkit`: Debugs iOS Safari/Chrome-iOS rendering bugs — stale paints, viewport/browser-chrome clipping, mobile-only CSS…
11
- - `forge-cli`: Manage Laravel Forge servers, sites, and provisioned resources from the terminal with the Laravel Forge CLI,…
11
+ - `forge-cli`: Manage Laravel Forge organizations, servers, sites, and provisioned resources with Forge CLI 2.x, falling…
12
12
  - `horizon-component-migration`: Bundle Shopify Horizon components into a migration package for a different theme, including recursive…
13
13
  - `playwright-cli`: Automates browser interactions for web testing, screenshots, and data extraction.
14
14
  - `pr-solver`: Resolve GitHub pull request feedback by querying unresolved review conversations with the GitHub GraphQL API…
@@ -2,18 +2,24 @@
2
2
  name: barrage
3
3
  description: |
4
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.
5
+ file, and maintain an existing queue. Fetches the items via the Basecamp CLI,
6
+ maps each title -> task id and description -> prompt, and writes queue.json into
7
+ the `.agents` copy of this skill (one shared queue, whichever install runs).
8
+ Also resets tasks to 0 attempts so they run again, and deletes all tasks from a
9
+ queue. Use when the user wants to turn Basecamp work
10
+ into a barrage queue, build a td-barrage queue from Basecamp, run a
11
+ todolist/card table through td-barrage, reset/retry queue tasks, or clear/empty
12
+ a barrage queue.
9
13
  invocable: true
10
- argument-hint: "[basecamp todolist/card-table/column URL] [--timeout-mins N]"
14
+ argument-hint: "[basecamp todolist/card-table/column URL] [--timeout-mins N] | reset | clear"
11
15
  ---
12
16
 
13
17
  # /barrage — Basecamp → td-barrage queue builder
14
18
 
15
19
  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
20
+ format consumed by **td-barrage**, a batch task runner, and maintains that file
21
+ afterwards (see [Maintaining an existing queue](#maintaining-an-existing-queue)
22
+ for resetting attempts and clearing tasks; see
17
23
  [Output: the td-barrage queue.json format](#output-the-td-barrage-queuejson-format)
18
24
  below for the full schema — this skill emits that format directly and needs no
19
25
  other repo installed).
@@ -50,9 +56,15 @@ Each Basecamp item becomes one td-barrage task:
50
56
  > `timeoutMs` (**milliseconds**), and `maxAttempts` — this skill emits the real
51
57
  > field names (documented in full below).
52
58
 
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).
59
+ The file is written to `.agents/skills/barrage/queue.json` the **`.agents` copy
60
+ of this skill**, regardless of which copy is running. The toolkit installs the skill
61
+ into every configured agent root (`.claude/`, `.agents/`, ), each with its own
62
+ scripts; routing every write to `.agents` keeps one shared queue instead of a
63
+ per-agent one, so td-barrage always runs the file you just built. If there is no
64
+ `.agents/` directory beside the running copy (or the skill is not laid out as
65
+ `<root>/skills/barrage/`, as in the toolkit source checkout), the queue falls back
66
+ to the running copy's own directory. `scripts/queue_paths.py` holds that rule; both
67
+ scripts default to it and `--output` / `--queue` still override it.
56
68
 
57
69
  ## Workflow
58
70
 
@@ -111,21 +123,65 @@ basecamp todos list --list <id> --in <proj> --json \
111
123
  ```
112
124
 
113
125
  `$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.).
126
+ `queue.json` into the `.agents` copy of the skill (see above) and prints a summary
127
+ plus any per-item warnings (skipped items with no description, deduped ids, etc.).
128
+ `--help` shows the resolved path.
116
129
 
117
130
  Options:
118
131
 
119
132
  - `--timeout-mins N` — override the 35-minute default (the script converts to ms).
120
133
  - `--max-attempts N` — override the default of 2.
121
134
  - `--keep-estimate` — keep the `[Nh]` suffix in the id instead of stripping it.
122
- - `--output PATH` — write somewhere other than `<skill>/queue.json`.
135
+ - `--output PATH` — write somewhere other than the shared `.agents` queue.
123
136
 
124
137
  ### 5. Report
125
138
 
126
139
  Tell the user where `queue.json` was written, how many tasks it contains, and
127
140
  surface any warnings the script printed (skipped or renamed items).
128
141
 
142
+ ## Maintaining an existing queue
143
+
144
+ `scripts/manage_queue.py` edits a queue file in place — use it when the user asks
145
+ to retry/reset tasks or to empty the queue. Both subcommands default to the same
146
+ shared `.agents/skills/barrage/queue.json` that `build_queue.py` writes; pass
147
+ `--queue PATH` for a queue elsewhere. Both write a
148
+ `<queue>.bak` copy first (`--no-backup` opts out) and preserve the file's
149
+ top-level shape (`{"tasks": [...]}` stays an object, a bare `[...]` stays an array).
150
+
151
+ ### Reset tasks to 0 attempts
152
+
153
+ ```bash
154
+ python3 "$SKILL_DIR/scripts/manage_queue.py" reset
155
+ ```
156
+
157
+ By default this is a **full re-run reset**: `attempts` → `0`, `status` →
158
+ `pending`, and the runtime fields `error` / `startedAt` / `finishedAt` are
159
+ dropped. Zeroing `attempts` alone is not enough — td-barrage skips any task whose
160
+ status is already `done` or `failed`, so the status reset is what actually makes
161
+ the task run again. Authored fields (`prompt`, `dependsOn`, `maxAttempts`,
162
+ `timeoutMs`, `cwd`, `success`) and the `recoveryNotes` history are untouched.
163
+
164
+ - `--status STATUS` — only reset tasks in that status (repeatable). `--status failed`
165
+ is the common case: retry the failures and leave completed work alone.
166
+ - `--attempts-only` — zero `attempts` and change nothing else (the literal, narrow
167
+ reset; the task still will not re-run if its status is `done` or `failed`).
168
+ - `--dry-run` — print the per-task changes without writing.
169
+
170
+ The script prints one line per changed task (`<id>: attempts 2 -> 0, status failed
171
+ -> pending`); relay that summary.
172
+
173
+ ### Delete all tasks
174
+
175
+ ```bash
176
+ python3 "$SKILL_DIR/scripts/manage_queue.py" clear --yes
177
+ ```
178
+
179
+ Empties the `tasks` array, leaving a valid but empty queue file. `--yes` is
180
+ **required**: without it the command only reports how many tasks it would delete
181
+ and exits non-zero without writing. Run it bare first to show the user the count,
182
+ then re-run with `--yes` once they confirm. The pre-clear queue is recoverable
183
+ from `<queue>.bak`.
184
+
129
185
  ## Output: the td-barrage queue.json format
130
186
 
131
187
  td-barrage validates the file with a Zod schema. This skill emits the minimal
@@ -33,6 +33,15 @@ from typing import Any
33
33
  # Skill root is the parent of this script's directory (scripts/ -> <skill>/).
34
34
  SKILL_DIR = Path(__file__).resolve().parent.parent
35
35
 
36
+ # Importable whether this file is run as a script or loaded by path (tests).
37
+ if str(Path(__file__).resolve().parent) not in sys.path:
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ from queue_paths import resolve_queue_path # noqa: E402
40
+
41
+ # The queue is shared across installs: always the `.agents` copy when there is
42
+ # one, whichever copy of the skill is running (see queue_paths.py).
43
+ DEFAULT_QUEUE = resolve_queue_path(SKILL_DIR)
44
+
36
45
  # Matches a trailing estimate suffix like " [2h]" or " [1.5h]" (case-insensitive).
37
46
  # Basecamp titles produced by an estimation workflow carry this suffix (the
38
47
  # td-augury generator appends "[<hours>h]"); we strip it from the task id so the
@@ -201,8 +210,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
201
210
  parser.add_argument(
202
211
  "--output",
203
212
  type=Path,
204
- default=SKILL_DIR / "queue.json",
205
- help="Where to write the queue file (default: <skill>/queue.json).",
213
+ default=DEFAULT_QUEUE,
214
+ help=f"Where to write the queue file (default: {DEFAULT_QUEUE}).",
206
215
  )
207
216
  parser.add_argument(
208
217
  "--timeout-mins",
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """Maintenance tools for an existing td-barrage queue.json.
3
+
4
+ Two subcommands operate on a queue file in place:
5
+
6
+ reset - zero the attempt counters so tasks can run again. By default this is a
7
+ full re-run reset: attempts -> 0, status -> "pending", and the runtime
8
+ fields error/startedAt/finishedAt are dropped (attempts alone is not
9
+ enough -- td-barrage skips a task whose status is already "done" or
10
+ "failed"). Pass --attempts-only for the literal, narrow reset.
11
+ clear - delete every task, leaving an empty but still-valid queue file.
12
+
13
+ Both preserve the file's top-level shape: a `{"tasks": [...]}` object stays an
14
+ object, a bare `[...]` array stays an array. The schema is documented in
15
+ ../SKILL.md ("Output: the td-barrage queue.json format").
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import shutil
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ # Skill root is the parent of this script's directory (scripts/ -> <skill>/).
28
+ SKILL_DIR = Path(__file__).resolve().parent.parent
29
+
30
+ # Importable whether this file is run as a script or loaded by path (tests).
31
+ if str(Path(__file__).resolve().parent) not in sys.path:
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+ from queue_paths import resolve_queue_path # noqa: E402
34
+
35
+ # The queue is shared across installs: always the `.agents` copy when there is
36
+ # one, whichever copy of the skill is running (see queue_paths.py).
37
+ DEFAULT_QUEUE = resolve_queue_path(SKILL_DIR)
38
+
39
+ # The td-barrage task statuses (src/schema.ts: taskStatuses).
40
+ TASK_STATUSES = ("pending", "running", "done", "failed", "blocked")
41
+
42
+ # Runtime-managed fields cleared by a full reset. `recoveryNotes` is deliberately
43
+ # left alone: it is an append-only history that does not block a re-run.
44
+ _RUNTIME_FIELDS = ("error", "startedAt", "finishedAt")
45
+
46
+
47
+ def load_queue(path: Path) -> tuple[Any, list[dict[str, Any]]]:
48
+ """Return (payload, tasks) where tasks is the live list inside payload."""
49
+ try:
50
+ raw = path.read_text()
51
+ except FileNotFoundError:
52
+ raise SystemExit(f"Queue file not found: {path}")
53
+
54
+ try:
55
+ payload = json.loads(raw)
56
+ except json.JSONDecodeError as exc:
57
+ raise SystemExit(f"Failed to parse {path}: {exc}")
58
+
59
+ if isinstance(payload, dict) and isinstance(payload.get("tasks"), list):
60
+ tasks = payload["tasks"]
61
+ elif isinstance(payload, list):
62
+ tasks = payload
63
+ else:
64
+ raise SystemExit(
65
+ f"{path} is not a td-barrage queue file "
66
+ '(expected {"tasks": [...]} or a bare [...] array).'
67
+ )
68
+
69
+ return payload, [task for task in tasks if isinstance(task, dict)]
70
+
71
+
72
+ def write_queue(path: Path, payload: Any, tasks: list[dict[str, Any]], *, backup: bool) -> Path | None:
73
+ """Write `tasks` back into `payload`'s shape. Returns the backup path, if any."""
74
+ backup_path: Path | None = None
75
+ if backup and path.exists():
76
+ backup_path = path.with_suffix(path.suffix + ".bak")
77
+ shutil.copy2(path, backup_path)
78
+
79
+ if isinstance(payload, dict):
80
+ payload["tasks"] = tasks
81
+ out: Any = payload
82
+ else:
83
+ out = tasks
84
+
85
+ path.write_text(json.dumps(out, indent=2) + "\n")
86
+ return backup_path
87
+
88
+
89
+ def reset_tasks(
90
+ tasks: list[dict[str, Any]],
91
+ *,
92
+ attempts_only: bool,
93
+ statuses: tuple[str, ...] | None,
94
+ ) -> list[str]:
95
+ """Reset matching tasks in place; returns a description per changed task."""
96
+ changed: list[str] = []
97
+
98
+ for task in tasks:
99
+ status = task.get("status", "pending")
100
+ if statuses and status not in statuses:
101
+ continue
102
+
103
+ before = dict(task)
104
+ task["attempts"] = 0
105
+ if not attempts_only:
106
+ task["status"] = "pending"
107
+ for field in _RUNTIME_FIELDS:
108
+ task.pop(field, None)
109
+
110
+ if task != before:
111
+ was = before.get("attempts", 0)
112
+ detail = f"attempts {was} -> 0"
113
+ if not attempts_only and before.get("status", "pending") != "pending":
114
+ detail += f", status {before['status']} -> pending"
115
+ changed.append(f"{task.get('id', '<no id>')}: {detail}")
116
+
117
+ return changed
118
+
119
+
120
+ def cmd_reset(args: argparse.Namespace) -> int:
121
+ payload, tasks = load_queue(args.queue)
122
+ if not tasks:
123
+ print(f"{args.queue} contains no tasks; nothing to reset.")
124
+ return 0
125
+
126
+ statuses = tuple(args.status) if args.status else None
127
+ changed = reset_tasks(tasks, attempts_only=args.attempts_only, statuses=statuses)
128
+
129
+ if not changed:
130
+ scope = f" with status {', '.join(statuses)}" if statuses else ""
131
+ print(f"No task{scope} needed resetting in {args.queue}.")
132
+ return 0
133
+
134
+ if args.dry_run:
135
+ print(f"Would reset {len(changed)} of {len(tasks)} task(s) in {args.queue}:")
136
+ for line in changed:
137
+ print(f" {line}")
138
+ print("Nothing written (--dry-run).")
139
+ return 0
140
+
141
+ backup_path = write_queue(args.queue, payload, tasks, backup=args.backup)
142
+ print(f"Reset {len(changed)} of {len(tasks)} task(s) in {args.queue}:")
143
+ for line in changed:
144
+ print(f" {line}")
145
+ if backup_path:
146
+ print(f"Backup written to {backup_path}.")
147
+ return 0
148
+
149
+
150
+ def cmd_clear(args: argparse.Namespace) -> int:
151
+ payload, tasks = load_queue(args.queue)
152
+ if not tasks:
153
+ print(f"{args.queue} already contains no tasks.")
154
+ return 0
155
+
156
+ if not args.yes:
157
+ print(f"Would delete all {len(tasks)} task(s) from {args.queue}.")
158
+ print("Nothing written — re-run with --yes to apply.", file=sys.stderr)
159
+ return 1
160
+
161
+ backup_path = write_queue(args.queue, payload, [], backup=args.backup)
162
+ print(f"Deleted all {len(tasks)} task(s) from {args.queue}.")
163
+ if backup_path:
164
+ print(f"Backup written to {backup_path}.")
165
+ return 0
166
+
167
+
168
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
169
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
170
+ subparsers = parser.add_subparsers(dest="command", required=True)
171
+
172
+ def add_common(sub: argparse.ArgumentParser) -> None:
173
+ sub.add_argument(
174
+ "--queue",
175
+ type=Path,
176
+ default=DEFAULT_QUEUE,
177
+ help=f"Queue file to modify (default: {DEFAULT_QUEUE}).",
178
+ )
179
+ sub.add_argument(
180
+ "--no-backup",
181
+ dest="backup",
182
+ action="store_false",
183
+ help="Skip writing a <queue>.bak copy before modifying the file.",
184
+ )
185
+
186
+ reset = subparsers.add_parser(
187
+ "reset",
188
+ help="Reset tasks to 0 attempts (and back to pending) so they run again.",
189
+ )
190
+ add_common(reset)
191
+ reset.add_argument(
192
+ "--attempts-only",
193
+ action="store_true",
194
+ help="Only zero `attempts`; leave status and error/startedAt/finishedAt alone.",
195
+ )
196
+ reset.add_argument(
197
+ "--status",
198
+ action="append",
199
+ choices=TASK_STATUSES,
200
+ help="Only reset tasks in this status (repeatable; default: all tasks).",
201
+ )
202
+ reset.add_argument(
203
+ "--dry-run",
204
+ action="store_true",
205
+ help="Print what would change without writing the file.",
206
+ )
207
+ reset.set_defaults(func=cmd_reset)
208
+
209
+ clear = subparsers.add_parser(
210
+ "clear",
211
+ help="Delete every task, leaving an empty queue file.",
212
+ )
213
+ add_common(clear)
214
+ clear.add_argument(
215
+ "--yes",
216
+ action="store_true",
217
+ help="Required to actually delete; without it the command only reports.",
218
+ )
219
+ clear.set_defaults(func=cmd_clear)
220
+
221
+ return parser.parse_args(argv)
222
+
223
+
224
+ def main(argv: list[str] | None = None) -> int:
225
+ args = parse_args(argv)
226
+ return args.func(args)
227
+
228
+
229
+ if __name__ == "__main__":
230
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve the one canonical location for this skill's queue.json.
3
+
4
+ The toolkit installs the barrage skill into every configured agent root — by
5
+ default both `.claude/skills/barrage/` and `.agents/skills/barrage/`. Each copy
6
+ carries its own scripts, so "write queue.json next to the script" would give
7
+ each agent a private queue and td-barrage would run whichever one happened to be
8
+ built last. Instead `.agents` is treated as the canonical home: whichever copy is
9
+ invoked, the queue is read from and written to the `.agents` one.
10
+
11
+ Fallbacks (the queue stays next to the running copy) apply when there is no
12
+ `.agents` install to point at, or when the skill is not laid out as
13
+ `<root>/skills/<name>/` — e.g. the toolkit source checkout itself.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ # The agent root that owns the shared queue, and the layout every install uses.
21
+ CANONICAL_ROOT = ".agents"
22
+ SKILLS_DIR_NAME = "skills"
23
+
24
+ QUEUE_FILENAME = "queue.json"
25
+
26
+
27
+ def resolve_queue_dir(skill_dir: Path) -> Path:
28
+ """Return the directory the queue lives in for a skill installed at `skill_dir`."""
29
+ skill_dir = Path(skill_dir).resolve()
30
+ if skill_dir.parent.name != SKILLS_DIR_NAME:
31
+ return skill_dir
32
+
33
+ install_root = skill_dir.parent.parent
34
+ if install_root.name == CANONICAL_ROOT:
35
+ return skill_dir
36
+ # A non-dot parent means this is not an installed copy (the toolkit source
37
+ # checkout, a tarball); there is no project root to hang `.agents` off.
38
+ if not install_root.name.startswith("."):
39
+ return skill_dir
40
+
41
+ canonical_root = install_root.parent / CANONICAL_ROOT
42
+ if not canonical_root.is_dir():
43
+ return skill_dir
44
+
45
+ return canonical_root / SKILLS_DIR_NAME / skill_dir.name
46
+
47
+
48
+ def resolve_queue_path(skill_dir: Path, filename: str = QUEUE_FILENAME) -> Path:
49
+ """Return the canonical queue file path for a skill installed at `skill_dir`."""
50
+ return resolve_queue_dir(skill_dir) / filename
@@ -0,0 +1,176 @@
1
+ """Tests for manage_queue.py — the reset / clear queue maintenance tools.
2
+
3
+ Run: python3 -m pytest .agents/skills/barrage/tests/ (or just `python3 test_manage_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" / "manage_queue.py"
14
+ _spec = importlib.util.spec_from_file_location("manage_queue", _SCRIPT)
15
+ assert _spec and _spec.loader
16
+ mq = importlib.util.module_from_spec(_spec)
17
+ _spec.loader.exec_module(mq)
18
+
19
+
20
+ # A queue mid-run: one exhausted failure, one success, one untouched task.
21
+ QUEUE = {
22
+ "tasks": [
23
+ {
24
+ "id": "Failed task",
25
+ "prompt": "do the thing",
26
+ "status": "failed",
27
+ "attempts": 2,
28
+ "maxAttempts": 2,
29
+ "error": "timed out",
30
+ "startedAt": "2026-09-11T10:00:00.000Z",
31
+ "finishedAt": "2026-09-11T10:35:00.000Z",
32
+ },
33
+ {"id": "Done task", "prompt": "already done", "status": "done", "attempts": 1},
34
+ {"id": "Fresh task", "prompt": "not started", "status": "pending", "attempts": 0},
35
+ ]
36
+ }
37
+
38
+
39
+ def write_queue(tmp_path: Path, payload=None) -> Path:
40
+ path = tmp_path / "queue.json"
41
+ path.write_text(json.dumps(payload if payload is not None else QUEUE, indent=2))
42
+ return path
43
+
44
+
45
+ def read_tasks(path: Path):
46
+ data = json.loads(path.read_text())
47
+ return data["tasks"] if isinstance(data, dict) else data
48
+
49
+
50
+ def test_reset_zeroes_attempts_and_clears_runtime_state(tmp_path):
51
+ path = write_queue(tmp_path)
52
+ rc = mq.main(["reset", "--queue", str(path)])
53
+ assert rc == 0
54
+ tasks = read_tasks(path)
55
+ assert [t["attempts"] for t in tasks] == [0, 0, 0]
56
+ assert [t["status"] for t in tasks] == ["pending", "pending", "pending"]
57
+ failed = tasks[0]
58
+ assert "error" not in failed and "startedAt" not in failed and "finishedAt" not in failed
59
+ # Authored fields survive.
60
+ assert failed["prompt"] == "do the thing"
61
+ assert failed["maxAttempts"] == 2
62
+
63
+
64
+ def test_reset_attempts_only_leaves_status_and_error(tmp_path):
65
+ path = write_queue(tmp_path)
66
+ rc = mq.main(["reset", "--queue", str(path), "--attempts-only"])
67
+ assert rc == 0
68
+ failed = read_tasks(path)[0]
69
+ assert failed["attempts"] == 0
70
+ assert failed["status"] == "failed"
71
+ assert failed["error"] == "timed out"
72
+
73
+
74
+ def test_reset_status_filter(tmp_path):
75
+ path = write_queue(tmp_path)
76
+ rc = mq.main(["reset", "--queue", str(path), "--status", "failed"])
77
+ assert rc == 0
78
+ tasks = read_tasks(path)
79
+ assert tasks[0]["status"] == "pending" and tasks[0]["attempts"] == 0
80
+ # The done task is untouched.
81
+ assert tasks[1]["status"] == "done" and tasks[1]["attempts"] == 1
82
+
83
+
84
+ def test_reset_dry_run_writes_nothing(tmp_path):
85
+ path = write_queue(tmp_path)
86
+ before = path.read_text()
87
+ rc = mq.main(["reset", "--queue", str(path), "--dry-run"])
88
+ assert rc == 0
89
+ assert path.read_text() == before
90
+ assert not (tmp_path / "queue.json.bak").exists()
91
+
92
+
93
+ def test_reset_writes_backup_by_default(tmp_path):
94
+ path = write_queue(tmp_path)
95
+ mq.main(["reset", "--queue", str(path)])
96
+ backup = tmp_path / "queue.json.bak"
97
+ assert backup.exists()
98
+ assert read_tasks(backup)[0]["attempts"] == 2
99
+
100
+
101
+ def test_reset_no_backup_flag(tmp_path):
102
+ path = write_queue(tmp_path)
103
+ mq.main(["reset", "--queue", str(path), "--no-backup"])
104
+ assert not (tmp_path / "queue.json.bak").exists()
105
+
106
+
107
+ def test_clear_requires_yes(tmp_path):
108
+ path = write_queue(tmp_path)
109
+ before = path.read_text()
110
+ rc = mq.main(["clear", "--queue", str(path)])
111
+ assert rc == 1
112
+ assert path.read_text() == before
113
+
114
+
115
+ def test_clear_deletes_all_tasks(tmp_path):
116
+ path = write_queue(tmp_path)
117
+ rc = mq.main(["clear", "--queue", str(path), "--yes"])
118
+ assert rc == 0
119
+ assert json.loads(path.read_text()) == {"tasks": []}
120
+ # The pre-clear queue is recoverable.
121
+ assert len(read_tasks(tmp_path / "queue.json.bak")) == 3
122
+
123
+
124
+ def test_clear_on_empty_queue_is_a_noop(tmp_path):
125
+ path = write_queue(tmp_path, {"tasks": []})
126
+ rc = mq.main(["clear", "--queue", str(path), "--yes"])
127
+ assert rc == 0
128
+ assert not (tmp_path / "queue.json.bak").exists()
129
+
130
+
131
+ def test_bare_array_shape_is_preserved(tmp_path):
132
+ path = write_queue(tmp_path, QUEUE["tasks"])
133
+ assert mq.main(["reset", "--queue", str(path)]) == 0
134
+ assert isinstance(json.loads(path.read_text()), list)
135
+ assert mq.main(["clear", "--queue", str(path), "--yes"]) == 0
136
+ assert json.loads(path.read_text()) == []
137
+
138
+
139
+ def test_missing_file_errors(tmp_path):
140
+ try:
141
+ mq.main(["reset", "--queue", str(tmp_path / "nope.json")])
142
+ except SystemExit as exc:
143
+ assert "not found" in str(exc)
144
+ else:
145
+ raise AssertionError("expected SystemExit for a missing queue file")
146
+
147
+
148
+ def test_non_queue_json_errors(tmp_path):
149
+ path = tmp_path / "queue.json"
150
+ path.write_text('{"ok": true}')
151
+ try:
152
+ mq.main(["clear", "--queue", str(path), "--yes"])
153
+ except SystemExit as exc:
154
+ assert "not a td-barrage queue file" in str(exc)
155
+ else:
156
+ raise AssertionError("expected SystemExit for a non-queue file")
157
+
158
+
159
+ if __name__ == "__main__":
160
+ failures = 0
161
+ for name, fn in sorted(globals().items()):
162
+ if name.startswith("test_") and callable(fn):
163
+ try:
164
+ # crude tmp_path for direct (non-pytest) runs
165
+ if "tmp_path" in fn.__code__.co_varnames[: fn.__code__.co_argcount]:
166
+ import tempfile
167
+
168
+ with tempfile.TemporaryDirectory() as d:
169
+ fn(Path(d))
170
+ else:
171
+ fn()
172
+ print(f"ok {name}")
173
+ except AssertionError as exc:
174
+ failures += 1
175
+ print(f"FAIL {name}: {exc}")
176
+ sys.exit(1 if failures else 0)
@@ -0,0 +1,97 @@
1
+ """Tests for queue_paths.py — the shared `.agents` queue location rule.
2
+
3
+ Run: python3 -m pytest .agents/skills/barrage/tests/ (or just `python3 test_queue_paths.py`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import importlib.util
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ _SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "queue_paths.py"
13
+ _spec = importlib.util.spec_from_file_location("queue_paths", _SCRIPT)
14
+ assert _spec and _spec.loader
15
+ qp = importlib.util.module_from_spec(_spec)
16
+ _spec.loader.exec_module(qp)
17
+
18
+
19
+ def install(root: Path, agent_root: str, name: str = "barrage") -> Path:
20
+ """Create <root>/<agent_root>/skills/<name>/ and return it."""
21
+ skill_dir = root / agent_root / "skills" / name
22
+ skill_dir.mkdir(parents=True)
23
+ return skill_dir
24
+
25
+
26
+ def test_claude_copy_points_at_the_agents_copy(tmp_path: Path):
27
+ claude = install(tmp_path, ".claude")
28
+ agents = install(tmp_path, ".agents")
29
+
30
+ assert qp.resolve_queue_path(claude) == agents / "queue.json"
31
+
32
+
33
+ def test_agents_copy_uses_itself(tmp_path: Path):
34
+ install(tmp_path, ".claude")
35
+ agents = install(tmp_path, ".agents")
36
+
37
+ assert qp.resolve_queue_path(agents) == agents / "queue.json"
38
+
39
+
40
+ def test_any_agent_root_redirects(tmp_path: Path):
41
+ codex = install(tmp_path, ".codex")
42
+ agents = install(tmp_path, ".agents")
43
+
44
+ assert qp.resolve_queue_path(codex) == agents / "queue.json"
45
+
46
+
47
+ def test_falls_back_when_there_is_no_agents_install(tmp_path: Path):
48
+ claude = install(tmp_path, ".claude")
49
+
50
+ assert qp.resolve_queue_path(claude) == claude / "queue.json"
51
+
52
+
53
+ def test_source_checkout_keeps_its_own_queue(tmp_path: Path):
54
+ """A non-dot root is the toolkit checkout, not an install — never redirect."""
55
+ checkout = install(tmp_path, "AgentToolkit")
56
+ install(tmp_path, ".agents")
57
+
58
+ assert qp.resolve_queue_path(checkout) == checkout / "queue.json"
59
+
60
+
61
+ def test_unfamiliar_layout_keeps_its_own_queue(tmp_path: Path):
62
+ loose = tmp_path / ".claude" / "barrage"
63
+ loose.mkdir(parents=True)
64
+ install(tmp_path, ".agents")
65
+
66
+ assert qp.resolve_queue_path(loose) == loose / "queue.json"
67
+
68
+
69
+ def test_redirect_target_need_not_exist_yet(tmp_path: Path):
70
+ """`.agents` exists but this skill is not installed there yet — still canonical."""
71
+ claude = install(tmp_path, ".claude")
72
+ (tmp_path / ".agents").mkdir()
73
+
74
+ assert qp.resolve_queue_path(claude) == tmp_path / ".agents" / "skills" / "barrage" / "queue.json"
75
+
76
+
77
+ def test_custom_filename(tmp_path: Path):
78
+ claude = install(tmp_path, ".claude")
79
+ agents = install(tmp_path, ".agents")
80
+
81
+ assert qp.resolve_queue_path(claude, "queue.bak.json") == agents / "queue.bak.json"
82
+
83
+
84
+ if __name__ == "__main__":
85
+ failures = 0
86
+ for name, fn in sorted(globals().items()):
87
+ if name.startswith("test_") and callable(fn):
88
+ try:
89
+ import tempfile
90
+
91
+ with tempfile.TemporaryDirectory() as d:
92
+ fn(Path(d))
93
+ print(f"ok {name}")
94
+ except AssertionError as exc:
95
+ failures += 1
96
+ print(f"FAIL {name}: {exc}")
97
+ sys.exit(1 if failures else 0)