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 +1 -1
- package/skills/README.md +2 -2
- package/skills/barrage/SKILL.md +68 -12
- package/skills/barrage/scripts/__pycache__/build_queue.cpython-312.pyc +0 -0
- package/skills/barrage/scripts/__pycache__/manage_queue.cpython-312.pyc +0 -0
- package/skills/barrage/scripts/__pycache__/queue_paths.cpython-312.pyc +0 -0
- package/skills/barrage/scripts/build_queue.py +11 -2
- package/skills/barrage/scripts/manage_queue.py +230 -0
- package/skills/barrage/scripts/queue_paths.py +50 -0
- package/skills/barrage/tests/test_manage_queue.py +176 -0
- package/skills/barrage/tests/test_queue_paths.py +97 -0
package/package.json
CHANGED
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
|
|
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…
|
package/skills/barrage/SKILL.md
CHANGED
|
@@ -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,
|
|
6
|
-
description -> prompt, and writes queue.json into
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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=
|
|
205
|
-
help="Where to write the queue file (default:
|
|
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)
|