Compare commits
3 Commits
addda6f54f
...
with/tasks
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c7c20b6e6 | |||
| 5725de2dac | |||
| 8eedf03486 |
55
.agents/skills/execute-tasks/SKILL.md
Normal file
55
.agents/skills/execute-tasks/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: execute-tasks
|
||||||
|
description: "Execute and complete a specified list of project tasks sequentially. Use when asked to work on, implement, execute, or do a set of task IDs. Supports writing, research, planning, software, and other projects; verifies results, records human review steps, and transitions completed work to testing."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Execute Tasks
|
||||||
|
|
||||||
|
## Orient
|
||||||
|
|
||||||
|
1. Read [AGENTS.md](../../../AGENTS.md),
|
||||||
|
[project-config.json](../../../project-config.json), and
|
||||||
|
[the task tracker](../../../docs/tasks.md).
|
||||||
|
2. Load the `task-management` skill and query each requested task with
|
||||||
|
`./scripts/get-task <ID>`. Do not assume a prefix or software project.
|
||||||
|
3. Confirm tasks are ready for work. If not, explain the status and ask for
|
||||||
|
direction; do not silently bypass blocked or unscoped work.
|
||||||
|
4. Inspect existing materials and changes. Preserve unrelated work.
|
||||||
|
|
||||||
|
## Execute Sequentially
|
||||||
|
|
||||||
|
For each task:
|
||||||
|
|
||||||
|
1. Transition it to `in_progress` using `update-task`.
|
||||||
|
2. Resolve material ambiguities with the user and agree on acceptance criteria.
|
||||||
|
3. Complete the requested deliverable using the project's established methods.
|
||||||
|
For a novel this may be manuscript edits; for research it may be source
|
||||||
|
analysis; for software it may be code. Do not invent a language or platform.
|
||||||
|
4. Update directly related documentation and verify acceptance criteria.
|
||||||
|
Use configured commands only when applicable. For non-command workflows,
|
||||||
|
review deliverables and report what still needs human judgment.
|
||||||
|
5. When editing the bundled task tooling, run
|
||||||
|
`python3 -m unittest discover -s scripts/tests -v`.
|
||||||
|
6. Record step-by-step human verification in `docs/task-verification.md`,
|
||||||
|
creating it if needed. Include expected results and limitations.
|
||||||
|
7. Transition successful work to `testing` with `--check-all`, then query
|
||||||
|
the task again to verify the transition. Never mark your own work `done`.
|
||||||
|
8. Record newly discovered work with `add-task`; do not expand scope silently.
|
||||||
|
|
||||||
|
Stop and explain blockers when an acceptance criterion cannot be met. Do not
|
||||||
|
present unverified work as complete.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
- Do not commit unless asked. If asked, inspect `git status` and include the
|
||||||
|
task/verification changes relevant to the deliverable. Do not stage unrelated
|
||||||
|
edits blindly.
|
||||||
|
- Use descriptive commit messages including task IDs and the required
|
||||||
|
co-author trailer where applicable.
|
||||||
|
- If project-description documents exist, check their last update and refresh
|
||||||
|
verification timestamps after major changes or 20 commits. Do not require
|
||||||
|
nonexistent project-specific documents.
|
||||||
|
- Human reviewers move `testing` to `done` and migrate verification steps to
|
||||||
|
`docs/human-tests.md`, creating it when first needed.
|
||||||
|
- If CLI tools cannot run, disclose that and agree on a manual workflow with
|
||||||
|
the user rather than reporting fabricated checks.
|
||||||
93
.agents/skills/initial-setup/SKILL.md
Normal file
93
.agents/skills/initial-setup/SKILL.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: initial-setup
|
||||||
|
description: "Set up or customize a freshly cloned generic project template. Use when asked for initial setup, getting started, onboarding, creating project configuration, or replacing sample project values. Interview the user and configure task tracking for any project, including novels, research, events, and software, without assuming a language or platform."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Initial Project Setup
|
||||||
|
|
||||||
|
Configure this clone for its actual project, not for the project that originally
|
||||||
|
supplied the template. This workflow is equally valid for a novel, research,
|
||||||
|
an event, or software.
|
||||||
|
|
||||||
|
## Inspect First
|
||||||
|
|
||||||
|
Read [the README](../../../README.md),
|
||||||
|
[project-config.json](../../../project-config.json),
|
||||||
|
[AGENTS.md](../../../AGENTS.md), and
|
||||||
|
[task frontmatter and details](../../../docs/tasks.md). Check Git status.
|
||||||
|
Preserve existing materials and uncommitted changes.
|
||||||
|
|
||||||
|
## Interview
|
||||||
|
|
||||||
|
Use the available user-question tool, one question at a time. Reuse answers
|
||||||
|
already given; do not ask again unnecessarily. Collect:
|
||||||
|
|
||||||
|
1. Project name.
|
||||||
|
2. A short description of its purpose, audience, and intended outcome.
|
||||||
|
3. Task ID prefix (recommend `EX` for an example; otherwise let the user choose).
|
||||||
|
Validate `[A-Za-z][A-Za-z0-9_-]*`; display IDs will be uppercase.
|
||||||
|
4. Whether there is one project category or several. Collect each label,
|
||||||
|
unique stable key, and repository-relative path (`.` for a single project).
|
||||||
|
5. How completed work should be verified. Ask about named commands only if
|
||||||
|
relevant. Do not assume tests, builds, a development server, a programming
|
||||||
|
language, or a target platform. Editorial review is a valid answer.
|
||||||
|
6. Whether to keep and adapt the sample task. Preserve real tasks unless the
|
||||||
|
user explicitly approves replacing them. Collect any desired task-type
|
||||||
|
changes; generic `task` and `chore` are sufficient for many projects.
|
||||||
|
|
||||||
|
Present a concise configuration summary and obtain confirmation before
|
||||||
|
replacing existing configuration or task content.
|
||||||
|
|
||||||
|
## Write the Configuration
|
||||||
|
|
||||||
|
Update `project-config.json` as valid JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Example Project",
|
||||||
|
"description": "Describe the project's purpose and intended outcome.",
|
||||||
|
"commands": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` and `description` must be non-empty strings. `commands` maps arbitrary
|
||||||
|
non-empty names to non-empty command strings. Leave it empty when no commands
|
||||||
|
are applicable. Record non-command acceptance/review criteria in AGENTS.md or
|
||||||
|
the task descriptions. Never store secrets or machine-specific absolute paths.
|
||||||
|
Commands are prompt guidance, not automatically run by the task-agent helpers.
|
||||||
|
|
||||||
|
Update task frontmatter with the agreed prefix and project categories.
|
||||||
|
Keep YAML in the flat format understood by the task parser: unindented scalar
|
||||||
|
fields, two-space list items, and four-space item fields. Do not introduce YAML
|
||||||
|
anchors, nested mappings, or multiline scalar values. Keep the existing
|
||||||
|
status lifecycle unless the user explicitly requests changes.
|
||||||
|
|
||||||
|
If renaming a project key, migrate matching task anchor `data-project`
|
||||||
|
attributes. Preserve task numbers, descriptions, checklists, epic/subproject
|
||||||
|
assignments, and existing internal links. Do not duplicate task configuration
|
||||||
|
in JSON. Update the README's project description and examples to match, keeping
|
||||||
|
Getting Started at the top. Update AGENTS.md only with confirmed project facts
|
||||||
|
and verification criteria; never invent an application architecture.
|
||||||
|
|
||||||
|
## Synchronize and Verify
|
||||||
|
|
||||||
|
If the optional CLI runtime is available:
|
||||||
|
|
||||||
|
1. Check whether `TASK_ID_PREFIX` or `TASKS_FILE` overrides the intended
|
||||||
|
configuration; explain conflicts rather than silently changing the result.
|
||||||
|
2. Run `./scripts/update-tasks -f docs/tasks.md`. This migrates IDs/internal
|
||||||
|
links and regenerates tables. Do not manually edit generated tables.
|
||||||
|
3. Run `./scripts/get-tasks --all -f docs/tasks.md` and query a task by number.
|
||||||
|
4. Run `python3 -m json.tool project-config.json` and
|
||||||
|
`python3 -m unittest discover -s scripts/tests -v`.
|
||||||
|
5. Verify the configured name, description, commands, project labels, and task
|
||||||
|
prefix persisted. Check for stale template identities and broken links.
|
||||||
|
6. Summarize changed files, verification results, and any remaining human review.
|
||||||
|
|
||||||
|
The tracker synchronizer currently requires at least one detailed task. Do
|
||||||
|
not remove the last task and claim synchronization succeeded; keep/adapt it or
|
||||||
|
ask how to handle an explicitly requested empty tracker.
|
||||||
|
|
||||||
|
If the runtime is unavailable, disclose that and offer either tool installation
|
||||||
|
or a manual Markdown workflow. Do not run application commands merely because
|
||||||
|
setup recorded them. Do not commit unless the user asks.
|
||||||
67
.agents/skills/task-management/SKILL.md
Normal file
67
.agents/skills/task-management/SKILL.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: task-management
|
||||||
|
description: "Manage, query, create, triage, research, and update project tasks using scripts/ and docs/tasks.md. Use when reading, creating, editing, prioritizing, or updating tasks, checklists, or project progress for any kind of project."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task Management
|
||||||
|
|
||||||
|
Read [the script guide](../../../scripts/README.md) and
|
||||||
|
[the tracker](../../../docs/tasks.md). Use its configured prefix, project keys,
|
||||||
|
statuses, and types, not an assumed project or software stack.
|
||||||
|
Examples below use task numbers so they work with any prefix.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
The optional CLI tools require Python 3.9+ and Bash or Zsh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./scripts/env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep local overrides in ignored `scripts/env.sh.local` or `.env`. These are
|
||||||
|
trusted shell scripts. `TASKS_FILE` chooses the tracker; `TASK_ID_PREFIX`
|
||||||
|
overrides its prefix. All task commands accept `-f /path/to/tasks.md`.
|
||||||
|
|
||||||
|
## Querying
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/get-tasks
|
||||||
|
./scripts/get-tasks --all
|
||||||
|
./scripts/get-tasks --planning
|
||||||
|
./scripts/get-tasks --accept "Status:Ready"
|
||||||
|
./scripts/get-task 1
|
||||||
|
./scripts/get-task 1 --json
|
||||||
|
./scripts/get-task 1 --description-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating and Updating
|
||||||
|
|
||||||
|
Read valid project/type/status values from frontmatter before invoking:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/add-task "Task Title" --project example-project --type task --status triage -d "Description and review criteria"
|
||||||
|
./scripts/update-task 1 --status in_progress
|
||||||
|
./scripts/update-task 1 --title "Updated Title"
|
||||||
|
./scripts/update-task 1 --append-description "- [ ] Additional criterion"
|
||||||
|
./scripts/update-task 1 --check-all
|
||||||
|
./scripts/update-task 1 --status testing
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--epic` or `--subproject` only with values defined in frontmatter.
|
||||||
|
Numbers and configured prefixed IDs are accepted.
|
||||||
|
|
||||||
|
## Synchronization and Review
|
||||||
|
|
||||||
|
- Anchor attributes in Detailed Tasks are the canonical task metadata.
|
||||||
|
- Do not manually edit generated summary/enum tables. Run
|
||||||
|
`./scripts/update-tasks` to regenerate them.
|
||||||
|
- Run `./scripts/get-task <number>` after edits to verify persisted values.
|
||||||
|
- `planning` / `triage` / `research` scope work; `ready` permits execution;
|
||||||
|
`in_progress` tracks active work; `testing` awaits human verification.
|
||||||
|
- `testing` applies to any project, including writing and research. It need
|
||||||
|
not involve automated tests. Humans confirm `done`.
|
||||||
|
- Optional `triage` / `research` helpers require a supported external agent CLI;
|
||||||
|
normal task management does not.
|
||||||
|
- If the CLI runtime is unavailable, explain that limitation. With user
|
||||||
|
agreement, maintain canonical task details in Markdown and ensure tables
|
||||||
|
remain consistent; do not silently claim script verification succeeded.
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -0,0 +1,7 @@
|
|||||||
|
.worktrees
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
scripts/env.sh.local
|
||||||
|
scripts/env.local.sh
|
||||||
|
|||||||
60
AGENTS.md
Normal file
60
AGENTS.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Repository Guide
|
||||||
|
|
||||||
|
This is a generic project starter, not an application scaffold. A project can
|
||||||
|
be a novel, research, an event, software, or anything else.
|
||||||
|
Read [project-config.json](./project-config.json) for the project name,
|
||||||
|
description, and any optional commands. Do not assume software, a language,
|
||||||
|
platform, framework, package manager, architecture, or deployment target.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- [README.md](./README.md): getting started and configuration instructions.
|
||||||
|
- [project-config.json](./project-config.json): project identity and commands.
|
||||||
|
- [docs/tasks.md](./docs/tasks.md): canonical task metadata and backlog.
|
||||||
|
- [scripts/](./scripts/): Python and shell task-management tools.
|
||||||
|
- [.agents/skills/](./.agents/skills/): shared setup and task workflows.
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
Use the `initial-setup` skill when cloning this template or reconfiguring a
|
||||||
|
project. Ask the user for the project name, description, task prefix, project
|
||||||
|
categories, and how they want to verify work. Ask about commands only when
|
||||||
|
relevant; a writing project might use editorial review instead. Preserve existing work.
|
||||||
|
Keep task prefixes and project categories in task frontmatter, not duplicated
|
||||||
|
in the JSON configuration.
|
||||||
|
|
||||||
|
## Working and Validation
|
||||||
|
|
||||||
|
- Verify work using the project's agreed criteria, whether editorial review,
|
||||||
|
checklists, research validation, or automated tests.
|
||||||
|
- Run commands only when configured and appropriate. Empty `commands` means
|
||||||
|
none are configured; do not invent a test, build, or development command.
|
||||||
|
- Using the bundled CLI tools requires Python 3.9+ and Bash or Zsh. Their tests
|
||||||
|
use only the Python standard library. A project using this template need
|
||||||
|
not use those tools or any programming language.
|
||||||
|
- When changing the bundled tooling, run
|
||||||
|
`python3 -m unittest discover -s scripts/tests -v`.
|
||||||
|
- Follow existing patterns and avoid unrelated changes.
|
||||||
|
- Use relative links in repository documentation.
|
||||||
|
- Never commit credentials or local environment overrides.
|
||||||
|
|
||||||
|
## Task Workflow
|
||||||
|
|
||||||
|
Use the `task-management` skill for task queries and edits, and `execute-tasks`
|
||||||
|
for implementation of specified task IDs.
|
||||||
|
|
||||||
|
- Use the task CLI scripts; do not manually edit generated summary/enum tables.
|
||||||
|
- Read the configured prefix and statuses from task frontmatter. Numbers alone
|
||||||
|
can be used when invoking task commands.
|
||||||
|
- Lifecycle: `planning` / `triage` / `research` -> `ready` -> `in_progress` ->
|
||||||
|
`testing` -> `done`. Here `testing` means awaiting human verification, not
|
||||||
|
necessarily software testing. Humans, not working agents, confirm `done`.
|
||||||
|
- Record human verification steps in `docs/task-verification.md` when a task
|
||||||
|
moves to `testing`; create that document when first needed.
|
||||||
|
- When human verification succeeds, migrate those steps to
|
||||||
|
`docs/human-tests.md`, creating it when needed.
|
||||||
|
- Commit only when requested. Include relevant task and verification changes,
|
||||||
|
but do not stage unrelated edits or commit another person's work blindly.
|
||||||
|
- If the project maintains project-description documents, refresh their
|
||||||
|
verification timestamps after major changes or 20 commits since their last
|
||||||
|
update. Do not assume those optional documents already exist.
|
||||||
88
README.md
88
README.md
@@ -0,0 +1,88 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
1. Clone this repository and enter the clone directory.
|
||||||
|
2. No project language or platform is prescribed. To use the optional bundled
|
||||||
|
task CLI tools, install **Python 3.9+** and use **Bash or Zsh**. The Markdown
|
||||||
|
tracker and agent skills can also be used without those tools.
|
||||||
|
3. In your coding assistant, request **"Run initial setup for this repository"**
|
||||||
|
or invoke `/initial-setup`. The shared skill asks for your project details
|
||||||
|
and creates your configuration without assuming software or an application
|
||||||
|
stack. A novel, research project, or event plan is equally valid.
|
||||||
|
4. If using the CLI tools, validate the tooling and inspect the sample task:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s scripts/tests -v
|
||||||
|
./scripts/get-task EX-001
|
||||||
|
./scripts/get-tasks --all
|
||||||
|
```
|
||||||
|
|
||||||
|
After choosing a different prefix, use your new ID or simply
|
||||||
|
`./scripts/get-task 1`.
|
||||||
|
|
||||||
|
5. Optionally load task commands and aliases into your shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./scripts/env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Project
|
||||||
|
|
||||||
|
A generic project starter with task tracking and reusable agent workflows.
|
||||||
|
It includes one sample task, not an application or deployment stack. The
|
||||||
|
project's subject, language, platform, and way of verifying work are unspecified.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
[project-config.json](./project-config.json) is the shared configuration for
|
||||||
|
the project name, description, and optional named commands. The `triage` and
|
||||||
|
`research` helpers read it when building agent prompts. Commands are guidance,
|
||||||
|
not automatically executed by those helpers. The sample's `commands` is empty.
|
||||||
|
Add commands only if relevant, for example a manuscript word-count command or
|
||||||
|
a software test command. Human review needs no command.
|
||||||
|
|
||||||
|
[docs/tasks.md](./docs/tasks.md) owns task configuration:
|
||||||
|
|
||||||
|
- `task-id-prefix`: `EX` in this example.
|
||||||
|
- `projects`: labels, keys, and repository-relative paths. The sample key is
|
||||||
|
`example-project`.
|
||||||
|
- `task-statuses`, `task-types`, and optional `epics` / `subprojects`.
|
||||||
|
|
||||||
|
For manual setup, edit the JSON configuration and task frontmatter, then run
|
||||||
|
`./scripts/update-tasks`. It regenerates tables and migrates task IDs and
|
||||||
|
internal links when the prefix changes. Preserve numeric IDs and existing
|
||||||
|
tasks. If changing project keys, also update their task anchor `data-project`
|
||||||
|
attributes before synchronizing.
|
||||||
|
|
||||||
|
`TASK_ID_PREFIX` overrides frontmatter; without either, scripts default to
|
||||||
|
`EX`. `TASKS_FILE` or `-f /path/to/tasks.md` selects another tracker.
|
||||||
|
Keep machine-specific overrides in ignored `scripts/env.sh.local` or `.env`,
|
||||||
|
which are sourced by the optional environment helper. These must contain
|
||||||
|
trusted shell syntax, not arbitrary dotenv syntax.
|
||||||
|
|
||||||
|
## Task Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/add-task "Outline the first deliverable" --project example-project --type task --status triage
|
||||||
|
./scripts/get-task 1 --json
|
||||||
|
./scripts/update-task 1 --status ready
|
||||||
|
./scripts/work-on-task 1 --start
|
||||||
|
./scripts/update-task 1 --status testing --check-all
|
||||||
|
./scripts/update-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Working agents move completed work to `testing`; humans verify it before
|
||||||
|
moving it to `done`. `testing` means review/verification, not necessarily
|
||||||
|
automated software testing.
|
||||||
|
|
||||||
|
`triage` and `research` optionally launch Hermes or Antigravity. Those external
|
||||||
|
CLIs are not required for the core task commands or tests. See the
|
||||||
|
[script guide](./scripts/README.md) for options.
|
||||||
|
|
||||||
|
## Shared Agent Skills
|
||||||
|
|
||||||
|
- **initial-setup**: interview the user and configure a fresh clone.
|
||||||
|
- **task-management**: query, add, update, and synchronize tasks.
|
||||||
|
- **execute-tasks**: implement specified tasks sequentially and record checks.
|
||||||
|
|
||||||
|
These live in [.agents/skills/](./.agents/skills/). The repository-wide
|
||||||
|
[agent guide](./AGENTS.md) keeps workflows independent of any particular stack.
|
||||||
135
docs/tasks.md
Normal file
135
docs/tasks.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
task-id-prefix: 'EX'
|
||||||
|
projects:
|
||||||
|
- label: 'Example Project'
|
||||||
|
path: '.'
|
||||||
|
value: 'example-project'
|
||||||
|
task-statuses:
|
||||||
|
- label: 'Planning'
|
||||||
|
value: 'planning'
|
||||||
|
description: 'Define the goals and scope.'
|
||||||
|
- label: 'Triage'
|
||||||
|
value: 'triage'
|
||||||
|
description: 'Decide how to approach the work.'
|
||||||
|
- label: 'Research'
|
||||||
|
value: 'research'
|
||||||
|
description: 'Investigate before planning or execution.'
|
||||||
|
- label: 'Ready'
|
||||||
|
value: 'ready'
|
||||||
|
description: 'Ready to be worked on.'
|
||||||
|
- label: 'In Progress'
|
||||||
|
value: 'in_progress'
|
||||||
|
description: 'Work is underway.'
|
||||||
|
- label: 'Testing'
|
||||||
|
value: 'testing'
|
||||||
|
description: 'Awaiting human review or verification.'
|
||||||
|
- label: 'Blocked'
|
||||||
|
value: 'blocked'
|
||||||
|
description: 'Waiting on a dependency or decision.'
|
||||||
|
- label: 'Done'
|
||||||
|
value: 'done'
|
||||||
|
description: 'Completed and verified by a human.'
|
||||||
|
- label: 'Cancelled'
|
||||||
|
value: 'cancelled'
|
||||||
|
description: 'No longer applicable.'
|
||||||
|
task-types:
|
||||||
|
- value: 'task'
|
||||||
|
label: 'Task'
|
||||||
|
description: 'A project deliverable or activity.'
|
||||||
|
- value: 'chore'
|
||||||
|
label: 'Chore'
|
||||||
|
description: 'Routine maintenance or administrative work.'
|
||||||
|
- value: 'feature'
|
||||||
|
label: 'Feature'
|
||||||
|
description: 'A new capability, when relevant to the project.'
|
||||||
|
- value: 'bug'
|
||||||
|
label: 'Bug'
|
||||||
|
description: 'A defect to fix, when relevant to the project.'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Task Tracker
|
||||||
|
|
||||||
|
## Working with Tasks
|
||||||
|
|
||||||
|
This tracker works for any project, including writing, research, and software.
|
||||||
|
`testing` means awaiting review or verification; it does not require automated
|
||||||
|
tests. This is a sample backlog. Customize the sample task for your project.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- `task-id-prefix` controls display IDs and anchors. This example uses `EX`.
|
||||||
|
- `TASK_ID_PREFIX` overrides frontmatter; the script default is `EX`.
|
||||||
|
- Prefixes start with a letter and contain letters, digits, underscores, or
|
||||||
|
hyphens. Display IDs are uppercase; anchors are lowercase.
|
||||||
|
- Numbers are globally monotonic with at least three digits. Use a number alone
|
||||||
|
with task commands to avoid dependence on the configured prefix.
|
||||||
|
- After changing the prefix, run `./scripts/update-tasks` to migrate IDs and
|
||||||
|
internal links without changing task numbers.
|
||||||
|
- `projects` defines labels, keys, and repository-relative paths. If renaming
|
||||||
|
a project key, update corresponding task anchor `data-project` attributes.
|
||||||
|
- All task commands accept `-f /path/to/tasks.md`; exported `TASKS_FILE` sets
|
||||||
|
the default tracker.
|
||||||
|
- Optional `epics` and `subprojects` can group work. Define their values and
|
||||||
|
labels in frontmatter before assigning them.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
The CLI tools require Python 3.9+ and Bash or Zsh. They are optional; you can
|
||||||
|
also maintain this Markdown tracker with your assistant.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./scripts/env.sh
|
||||||
|
./scripts/get-tasks --all
|
||||||
|
./scripts/get-tasks --accept "Status:Ready"
|
||||||
|
./scripts/get-task 1 --json
|
||||||
|
./scripts/add-task "Outline the next deliverable" --project example-project --type task --status triage
|
||||||
|
./scripts/update-task 1 --status in_progress
|
||||||
|
./scripts/update-task 1 --append-description "- [ ] Additional review criterion"
|
||||||
|
./scripts/update-task 1 --status testing --check-all
|
||||||
|
./scripts/update-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Task anchor attributes in **Detailed Tasks** are the canonical metadata.
|
||||||
|
Do not manually edit generated summary or enum tables; regenerate them with
|
||||||
|
`update-tasks`. Working agents transition completed work to `testing`, not
|
||||||
|
`done`. A human confirms completion.
|
||||||
|
|
||||||
|
See the [script guide](../scripts/README.md) and
|
||||||
|
[getting started guide](../README.md) for details.
|
||||||
|
|
||||||
|
<a id="tasks-summary"></a>
|
||||||
|
|
||||||
|
## Overall Progress
|
||||||
|
|
||||||
|
<a id="tasks-list"></a>
|
||||||
|
|
||||||
|
| ID | Title | Project | Status | Type |
|
||||||
|
|:-----|:-------------------------|:--------------|:-----|:--------------|
|
||||||
|
| EX-001 | Define the project's goals | Example Project | Ready | [Task](#ex-001) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Tasks
|
||||||
|
|
||||||
|
<a id="ex-001" class="task" data-project="example-project" data-status="ready" data-task-type="task"></a>
|
||||||
|
### Define the project's goals
|
||||||
|
**ID:** EX-001
|
||||||
|
**Project:** Example Project
|
||||||
|
**Status:** Ready
|
||||||
|
**Type:** Task
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Replace this sample with the first meaningful activity for your project.
|
||||||
|
- [ ] Describe the intended outcome and audience.
|
||||||
|
- [ ] Identify the first deliverable.
|
||||||
|
- [ ] Agree on how the deliverable will be reviewed or verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
#### Projects (Rendered from frontmatter projects)
|
||||||
|
|
||||||
|
#### Task Statuses (Rendered from frontmatter task-statuses)
|
||||||
|
|
||||||
|
#### Task Types (Rendered from frontmatter task-types)
|
||||||
5
project-config.json
Normal file
5
project-config.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Example Project",
|
||||||
|
"description": "A generic project starter with task tracking and reusable agent workflows.",
|
||||||
|
"commands": {}
|
||||||
|
}
|
||||||
77
scripts/README.md
Normal file
77
scripts/README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Project Scripts
|
||||||
|
|
||||||
|
Optional task-management tools for any kind of project. They require
|
||||||
|
Python 3.9+ and Bash or Zsh, but no third-party Python packages. They do not
|
||||||
|
prescribe the project's language, platform, or subject.
|
||||||
|
|
||||||
|
Tasks live in [docs/tasks.md](../docs/tasks.md). Project identity and optional
|
||||||
|
commands live in [project-config.json](../project-config.json).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./scripts/env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds scripts to `PATH` and defines task aliases. It sources trusted local
|
||||||
|
shell settings from `scripts/env.sh.local`, or `.env` if the first file is
|
||||||
|
absent. Both are ignored by Git. An existing `TASKS_FILE` is preserved.
|
||||||
|
`TASK_ID_PREFIX` overrides task frontmatter; the default prefix is `EX`.
|
||||||
|
All task commands accept `-f /path/to/tasks.md`.
|
||||||
|
|
||||||
|
## Query and Edit Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/get-tasks --all
|
||||||
|
./scripts/get-tasks --accept 'Status:Testing'
|
||||||
|
./scripts/get-tasks --planning
|
||||||
|
./scripts/get-tasks --sort asc
|
||||||
|
./scripts/get-task EX-001
|
||||||
|
./scripts/get-task 1 --json
|
||||||
|
./scripts/add-task "Describe the first deliverable" -p example-project -t task -s triage
|
||||||
|
./scripts/update-task 1 --status ready
|
||||||
|
./scripts/work-on-task 1 --start
|
||||||
|
./scripts/update-task 1 --status testing --check-all
|
||||||
|
./scripts/update-task 1 --append-description "- [ ] Additional review criterion"
|
||||||
|
./scripts/update-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
- `get-tasks`: triage tasks by default; `--all` includes completed work.
|
||||||
|
Supports column selection and filters; `--help` lists the options.
|
||||||
|
- `get-task`: metadata and full description for a prefixed ID or task number.
|
||||||
|
- `add-task`: assigns the next numeric ID. Supply a project key and type from
|
||||||
|
frontmatter; the legacy defaults are `shared` and `feature`.
|
||||||
|
- `update-task`: edits metadata, descriptions, and checklist items.
|
||||||
|
- `work-on-task`: validates readiness and optionally starts work.
|
||||||
|
- `update-tasks`: regenerates summary/enum tables and normalizes IDs and links.
|
||||||
|
|
||||||
|
`testing` means awaiting human verification, whether that is editorial review,
|
||||||
|
a planning checklist, or software tests. Agents do not mark their own work
|
||||||
|
`done`.
|
||||||
|
|
||||||
|
## Optional Agent Helpers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/triage 1 --hermes
|
||||||
|
./scripts/research 1 --agy
|
||||||
|
```
|
||||||
|
|
||||||
|
These launch an installed Hermes or Antigravity CLI. Core task commands and
|
||||||
|
tests do not need either. Select an agent with `--agent`, a shortcut above,
|
||||||
|
or `TASK_AGENT`. Missing agents are reported as errors.
|
||||||
|
|
||||||
|
`triage` accepts `planning` / `triage`; `research` accepts `research`.
|
||||||
|
Their prompts read the shared JSON configuration and refer to
|
||||||
|
[AGENTS.md](../AGENTS.md), rather than assuming a particular project or stack.
|
||||||
|
An empty `commands` object means no project commands are configured. Configured
|
||||||
|
commands are included as guidance, not executed by these helpers.
|
||||||
|
|
||||||
|
## Tooling Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s scripts/tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates the bundled tools, not your project's deliverables.
|
||||||
|
No release scripts are provided: publishing or deployment should be added only
|
||||||
|
when the project's needs are known.
|
||||||
243
scripts/add-task
Executable file
243
scripts/add-task
Executable file
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
add-task
|
||||||
|
|
||||||
|
Appends a new task to docs/tasks.md and re-synchronizes the task list.
|
||||||
|
Usage:
|
||||||
|
add-task "My Title Goes Here" --project=shared --type=chore
|
||||||
|
add-task "My Title Goes Here" -p example-project -t feature -s triage -d "Description text"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_update_tasks_module():
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
update_script = script_dir / "update-tasks"
|
||||||
|
if not update_script.exists():
|
||||||
|
raise FileNotFoundError(f"Could not find update-tasks at {update_script}")
|
||||||
|
loader = importlib.machinery.SourceFileLoader("update_tasks", str(update_script))
|
||||||
|
spec = importlib.util.spec_from_loader("update_tasks", loader)
|
||||||
|
if spec is None:
|
||||||
|
raise ImportError(f"Could not load spec for {update_script}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def add_task(title, project="shared", subproject=None, task_type="feature", status="triage", description=None, tasks_file=None, epic=None):
|
||||||
|
update_mod = load_update_tasks_module()
|
||||||
|
tasks_path = update_mod.find_tasks_file(tasks_file)
|
||||||
|
|
||||||
|
with open(tasks_path, "r", encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# 1. Parse frontmatter for valid labels and values
|
||||||
|
fm_match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
|
||||||
|
if not fm_match:
|
||||||
|
raise ValueError("YAML frontmatter not found in tasks.md")
|
||||||
|
|
||||||
|
fm_data = update_mod.parse_yaml_frontmatter(fm_match.group(1))
|
||||||
|
prefix = update_mod.get_task_id_prefix(fm_data)
|
||||||
|
epic_key, epic_label = update_mod.resolve_epic(epic, fm_data)
|
||||||
|
|
||||||
|
project_list = fm_data.get("projects", [])
|
||||||
|
subproject_list = fm_data.get("subprojects", [])
|
||||||
|
status_list = fm_data.get("task-statuses", [])
|
||||||
|
type_list = fm_data.get("task-types", [])
|
||||||
|
|
||||||
|
project_map = {}
|
||||||
|
project_value_map = {}
|
||||||
|
for p in project_list:
|
||||||
|
val = p.get("value", "").strip()
|
||||||
|
lbl = p.get("label", "").strip()
|
||||||
|
if val:
|
||||||
|
project_map[val.lower()] = lbl or val.capitalize()
|
||||||
|
project_value_map[val.lower()] = val
|
||||||
|
if lbl:
|
||||||
|
project_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
project_value_map[lbl.lower()] = val
|
||||||
|
|
||||||
|
subproject_map = {}
|
||||||
|
subproject_value_map = {}
|
||||||
|
for sp in subproject_list:
|
||||||
|
val = sp.get("value", "").strip()
|
||||||
|
lbl = sp.get("label", "").strip()
|
||||||
|
if val:
|
||||||
|
subproject_map[val.lower()] = lbl or val.replace("-", " ").title()
|
||||||
|
subproject_value_map[val.lower()] = val
|
||||||
|
if lbl:
|
||||||
|
subproject_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
subproject_value_map[lbl.lower()] = val
|
||||||
|
|
||||||
|
status_map = {s.get("value", "").lower(): s.get("label", "") for s in status_list}
|
||||||
|
status_value_map = {s.get("value", "").lower(): s.get("value", "") for s in status_list}
|
||||||
|
for s in status_list:
|
||||||
|
lbl = s.get("label", "").strip()
|
||||||
|
val = s.get("value", "").strip()
|
||||||
|
if lbl:
|
||||||
|
status_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
status_value_map[lbl.lower()] = val
|
||||||
|
status_map["fixed"] = "Fixed"
|
||||||
|
status_map["done"] = "Fixed"
|
||||||
|
status_value_map["fixed"] = "done"
|
||||||
|
status_value_map["done"] = "done"
|
||||||
|
|
||||||
|
type_map = {t.get("value", "").lower(): t.get("label", "") for t in type_list}
|
||||||
|
type_value_map = {t.get("value", "").lower(): t.get("value", "") for t in type_list}
|
||||||
|
for t in type_list:
|
||||||
|
lbl = t.get("label", "").strip()
|
||||||
|
val = t.get("value", "").strip()
|
||||||
|
if lbl:
|
||||||
|
type_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
type_value_map[lbl.lower()] = val
|
||||||
|
|
||||||
|
# Normalize values
|
||||||
|
proj_input = project.strip().lower()
|
||||||
|
type_input = task_type.strip().lower()
|
||||||
|
status_input = status.strip().lower()
|
||||||
|
|
||||||
|
proj_key = project_value_map.get(proj_input, proj_input)
|
||||||
|
type_key = type_value_map.get(type_input, type_input)
|
||||||
|
status_key = status_value_map.get(status_input, status_input)
|
||||||
|
|
||||||
|
proj_label = project_map.get(proj_key, project_map.get(proj_input, project.strip().capitalize()))
|
||||||
|
type_label = type_map.get(type_key, type_map.get(type_input, task_type.strip().capitalize()))
|
||||||
|
status_label = status_map.get(status_key, status_map.get(status_input, status.strip().capitalize()))
|
||||||
|
|
||||||
|
subproj_key = None
|
||||||
|
subproj_label = None
|
||||||
|
if subproject and subproject.strip() and subproject.strip().lower() not in ("", "none", "null"):
|
||||||
|
sp_in = subproject.strip().lower()
|
||||||
|
subproj_key = subproject_value_map.get(sp_in, sp_in)
|
||||||
|
subproj_label = subproject_map.get(subproj_key, subproject_map.get(sp_in, subproject.strip().replace("-", " ").replace("_", " ").title()))
|
||||||
|
|
||||||
|
# 2. Find max task ID
|
||||||
|
detail_start = text.find("## Detailed Tasks\n")
|
||||||
|
if detail_start == -1:
|
||||||
|
raise ValueError("## Detailed Tasks heading not found")
|
||||||
|
ids = [int(m.group(2)) for m in re.finditer(update_mod.TASK_ID_PATTERN, text[detail_start:], re.IGNORECASE)]
|
||||||
|
next_num = (max(ids) + 1) if ids else 1
|
||||||
|
|
||||||
|
# Digit width calculation (minimum 3 digits)
|
||||||
|
digit_width = max(3, len(str(next_num)))
|
||||||
|
formatted_id = update_mod.format_task_id(next_num, prefix, digit_width)
|
||||||
|
formatted_aid = formatted_id.lower()
|
||||||
|
|
||||||
|
# 3. Format description
|
||||||
|
if description and description.strip():
|
||||||
|
desc_lines = description.strip().splitlines()
|
||||||
|
desc_body = "\n".join(desc_lines)
|
||||||
|
if not any(line.strip().startswith("- [") for line in desc_lines):
|
||||||
|
desc_body += "\n- [ ] "
|
||||||
|
else:
|
||||||
|
desc_body = "Describe task objectives and implementation requirements here.\n- [ ] "
|
||||||
|
|
||||||
|
if subproj_key:
|
||||||
|
anchor_attrs = f'id="{formatted_aid}" class="task" data-project="{proj_key}" data-subproject="{subproj_key}" data-status="{status_key}" data-task-type="{type_key}"'
|
||||||
|
subproj_line = f"**Subproject:** {subproj_label}\n"
|
||||||
|
else:
|
||||||
|
anchor_attrs = f'id="{formatted_aid}" class="task" data-project="{proj_key}" data-status="{status_key}" data-task-type="{type_key}"'
|
||||||
|
subproj_line = ""
|
||||||
|
|
||||||
|
if epic_key:
|
||||||
|
anchor_attrs += f' data-epic="{epic_key}"'
|
||||||
|
epic_line = f"**Epic:** {epic_label}\n" if epic_key else ""
|
||||||
|
|
||||||
|
new_task_chunk = (
|
||||||
|
f'<a {anchor_attrs}></a>\n'
|
||||||
|
f"### {title.strip()}\n"
|
||||||
|
f"**ID:** {formatted_id}\n"
|
||||||
|
f"**Project:** {proj_label}\n"
|
||||||
|
f"{subproj_line}"
|
||||||
|
f"{epic_line}"
|
||||||
|
f"**Status:** {status_label}\n"
|
||||||
|
f"**Type:** {type_label}\n\n"
|
||||||
|
f"**Description:**\n"
|
||||||
|
f"{desc_body}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Insert before Notes marker
|
||||||
|
notes_marker = "\n---\n\n## Notes\n"
|
||||||
|
notes_pos = text.find(notes_marker)
|
||||||
|
if notes_pos == -1:
|
||||||
|
raise ValueError("Could not find ## Notes marker to insert task before")
|
||||||
|
|
||||||
|
updated_text = text[:notes_pos].rstrip() + "\n\n" + new_task_chunk + "\n---\n\n## Notes\n" + text[notes_pos + len(notes_marker):]
|
||||||
|
|
||||||
|
with open(tasks_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(updated_text)
|
||||||
|
|
||||||
|
# 5. Re-run update_tasks_document to sort, standardize, and refresh tables
|
||||||
|
update_mod.update_tasks_document(tasks_path)
|
||||||
|
|
||||||
|
return formatted_id, title.strip(), proj_label, subproj_label, status_label, type_label, tasks_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Add a new task to tasks.md and update tables.")
|
||||||
|
parser.add_argument("title", help="Title of the new task")
|
||||||
|
parser.add_argument("--epic", default=None, help="Epic value or label from frontmatter (optional)")
|
||||||
|
parser.add_argument(
|
||||||
|
"-p", "--project",
|
||||||
|
default="shared",
|
||||||
|
help="Project name or value (default: shared)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-S", "--subproject",
|
||||||
|
default=None,
|
||||||
|
help="Subproject name or category (optional, e.g. actions, expressions, schema)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t", "--type",
|
||||||
|
default="feature",
|
||||||
|
help="Task type (default: feature, e.g. chore, bug, feature, lint, docs)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s", "--status",
|
||||||
|
default="triage",
|
||||||
|
help="Initial task status (default: triage, e.g. planning, triage, pending, fixed)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d", "--description",
|
||||||
|
default=None,
|
||||||
|
help="Task description and requirements"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-f", "--file",
|
||||||
|
default=None,
|
||||||
|
help="Path to tasks.md (optional)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_id, title, proj, subproj, status, ttype, tasks_path = add_task(
|
||||||
|
title=args.title,
|
||||||
|
project=args.project,
|
||||||
|
subproject=args.subproject,
|
||||||
|
task_type=args.type,
|
||||||
|
status=args.status,
|
||||||
|
description=args.description,
|
||||||
|
tasks_file=args.file,
|
||||||
|
epic=args.epic,
|
||||||
|
)
|
||||||
|
tag_info = f"{proj} | {subproj} | {status} | {ttype}" if subproj else f"{proj} | {status} | {ttype}"
|
||||||
|
print(f"Successfully added {new_id}: \"{title}\" [{tag_info}] to {tasks_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding task: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
82
scripts/env.sh
Executable file
82
scripts/env.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# env.sh
|
||||||
|
#
|
||||||
|
# Environment configuration and aliases for task management scripts.
|
||||||
|
# Can be sourced (recommended) into your current shell session or executed directly.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source ./scripts/env.sh
|
||||||
|
# # or:
|
||||||
|
# . ./scripts/env.sh
|
||||||
|
#
|
||||||
|
|
||||||
|
# Resolve directory of this script portably across bash and zsh
|
||||||
|
if [ -n "${BASH_SOURCE[0]}" ]; then
|
||||||
|
_SCRIPT_SRC="${BASH_SOURCE[0]}"
|
||||||
|
elif [ -n "${ZSH_VERSION}" ]; then
|
||||||
|
_SCRIPT_SRC="${(%):-%x}"
|
||||||
|
else
|
||||||
|
_SCRIPT_SRC="$0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPTS_DIR="$(cd "$(dirname "$_SCRIPT_SRC")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPTS_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Add scripts directory to PATH if not already present
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$SCRIPTS_DIR:"*) ;;
|
||||||
|
*) export PATH="$SCRIPTS_DIR:$PATH" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Export Repo & Tasks Context
|
||||||
|
export REPO_ROOT
|
||||||
|
export TASKS_FILE="${TASKS_FILE:-$REPO_ROOT/docs/tasks.md}"
|
||||||
|
# Optional document-wide prefix override; unset uses frontmatter, then EX.
|
||||||
|
# export TASK_ID_PREFIX="EX"
|
||||||
|
|
||||||
|
# Define Aliases
|
||||||
|
alias get-ready-tasks="$SCRIPTS_DIR/get-tasks --accept 'Status:Ready' | sed -E 's/ +\\|$/ |/; s/-{10,}/---/'"
|
||||||
|
alias get-planning-tasks="$SCRIPTS_DIR/get-tasks --planning | sed -E 's/ +\\|$/ |/; s/-{10,}/---/'"
|
||||||
|
alias add-task="$SCRIPTS_DIR/add-task"
|
||||||
|
alias get-task="$SCRIPTS_DIR/get-task"
|
||||||
|
alias get-tasks="$SCRIPTS_DIR/get-tasks"
|
||||||
|
alias update-task="$SCRIPTS_DIR/update-task"
|
||||||
|
alias update-tasks="$SCRIPTS_DIR/update-tasks"
|
||||||
|
alias triage="$SCRIPTS_DIR/triage"
|
||||||
|
alias triage-hermes="$SCRIPTS_DIR/triage --hermes"
|
||||||
|
alias research="$SCRIPTS_DIR/research"
|
||||||
|
alias research-hermes="$SCRIPTS_DIR/research --hermes"
|
||||||
|
alias groom="$SCRIPTS_DIR/get-tasks --planning | sed -E 's/ +\\|$/ |/; s/-{10,}/---/'"
|
||||||
|
alias dp="$SCRIPTS_DIR/get-tasks --planning | sed -E 's/ +\\|$/ |/; s/-{10,}/---/'"
|
||||||
|
alias work-on-task="$SCRIPTS_DIR/work-on-task"
|
||||||
|
|
||||||
|
# Source optional local uncommitted environment settings if present
|
||||||
|
if [ -f "$SCRIPTS_DIR/env.sh.local" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$SCRIPTS_DIR/env.sh.local"
|
||||||
|
elif [ -f "$REPO_ROOT/.env" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$REPO_ROOT/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect whether this file is being sourced or executed directly
|
||||||
|
_IS_SOURCED=0
|
||||||
|
if [ -n "${ZSH_VERSION}" ]; then
|
||||||
|
case "$ZSH_EVAL_CONTEXT" in
|
||||||
|
*:file*) _IS_SOURCED=1 ;;
|
||||||
|
esac
|
||||||
|
elif [ -n "${BASH_VERSION}" ]; then
|
||||||
|
if [ "${BASH_SOURCE[0]}" != "$0" ]; then
|
||||||
|
_IS_SOURCED=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_IS_SOURCED" -eq 1 ]; then
|
||||||
|
echo "Task environment initialized. Scripts added to PATH and aliases defined."
|
||||||
|
else
|
||||||
|
echo "Task environment script executed."
|
||||||
|
echo "To load aliases and PATH into your current shell session, run:"
|
||||||
|
echo " source $SCRIPTS_DIR/env.sh"
|
||||||
|
echo " # or: . $SCRIPTS_DIR/env.sh"
|
||||||
|
fi
|
||||||
173
scripts/get-task
Executable file
173
scripts/get-task
Executable file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
get-task
|
||||||
|
|
||||||
|
Retrieves detailed information and metadata for a specific task in docs/tasks.md.
|
||||||
|
Usage:
|
||||||
|
./scripts/get-task EX-018
|
||||||
|
./scripts/get-task 18 --json
|
||||||
|
./scripts/get-task EX-099 --description-only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_update_tasks_module():
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
update_script = script_dir / "update-tasks"
|
||||||
|
if not update_script.exists():
|
||||||
|
raise FileNotFoundError(f"Could not find update-tasks at {update_script}")
|
||||||
|
loader = importlib.machinery.SourceFileLoader("update_tasks", str(update_script))
|
||||||
|
spec = importlib.util.spec_from_loader("update_tasks", loader)
|
||||||
|
if spec is None:
|
||||||
|
raise ImportError(f"Could not load spec for {update_script}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def parse_task_id(id_str):
|
||||||
|
return load_update_tasks_module().parse_task_id(id_str)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id_input, tasks_file=None):
|
||||||
|
update_mod = load_update_tasks_module()
|
||||||
|
tasks_path = update_mod.find_tasks_file(tasks_file)
|
||||||
|
|
||||||
|
with open(tasks_path, "r", encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
fm_data = update_mod.read_frontmatter(text)
|
||||||
|
prefix = update_mod.get_task_id_prefix(fm_data)
|
||||||
|
target_num = update_mod.parse_task_id(task_id_input)
|
||||||
|
|
||||||
|
detail_header_marker = "## Detailed Tasks\n"
|
||||||
|
detail_start = text.find(detail_header_marker)
|
||||||
|
if detail_start == -1:
|
||||||
|
raise ValueError("## Detailed Tasks heading not found")
|
||||||
|
|
||||||
|
notes_marker = "\n---\n\n## Notes\n"
|
||||||
|
notes_start = text.find(notes_marker)
|
||||||
|
if notes_start == -1:
|
||||||
|
notes_start = len(text)
|
||||||
|
|
||||||
|
detail_body = text[detail_start + len(detail_header_marker) : notes_start]
|
||||||
|
task_chunks = re.split(r"(?=<a\s+[^>]*\bid=|<id=)", detail_body)
|
||||||
|
|
||||||
|
for chunk in task_chunks:
|
||||||
|
chunk_clean = chunk.strip()
|
||||||
|
if not chunk_clean:
|
||||||
|
continue
|
||||||
|
m = re.search(update_mod.TASK_ID_PATTERN, chunk_clean, re.IGNORECASE)
|
||||||
|
if m and int(m.group(2)) == target_num and update_mod.task_id_matches(task_id_input, m.group(1), prefix):
|
||||||
|
anchor_match = re.search(update_mod.TASK_ANCHOR_PATTERN, chunk_clean, re.IGNORECASE)
|
||||||
|
anchor_tag = anchor_match.group(1) if anchor_match else ""
|
||||||
|
|
||||||
|
proj_m = re.search(r'data-project=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
subproj_m = re.search(r'data-subproject=["\']([^"\']*)["\']', anchor_tag)
|
||||||
|
status_m = re.search(r'data-status=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
type_m = re.search(r'data-task-type=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
epic_m = re.search(r'data-epic=["\']([^"\']*)["\']', anchor_tag)
|
||||||
|
epic, epic_label = update_mod.resolve_epic(epic_m.group(1) if epic_m else None, fm_data)
|
||||||
|
|
||||||
|
if not subproj_m:
|
||||||
|
subproj_field_m = re.search(r'^\*\*Subproject:\*\*\s*([^\n]+)$', chunk_clean, re.MULTILINE)
|
||||||
|
subproject = subproj_field_m.group(1).strip() if subproj_field_m else ""
|
||||||
|
else:
|
||||||
|
subproject = subproj_m.group(1).strip()
|
||||||
|
|
||||||
|
title_m = re.search(r'^###\s+(.+)$', chunk_clean, re.MULTILINE)
|
||||||
|
title = title_m.group(1).strip() if title_m else f"Task {target_num}"
|
||||||
|
|
||||||
|
desc_m = re.search(r'\*\*Description:\*\*\s*\n(.*)$', chunk_clean, re.DOTALL)
|
||||||
|
description = desc_m.group(1).strip() if desc_m else ""
|
||||||
|
|
||||||
|
digit_width = max(3, len(m.group(1).rsplit("-", 1)[1]))
|
||||||
|
formatted_id = update_mod.format_task_id(target_num, prefix, digit_width)
|
||||||
|
formatted_aid = m.group(1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"number": target_num,
|
||||||
|
"id": formatted_id,
|
||||||
|
"anchor_id": formatted_aid,
|
||||||
|
"title": title,
|
||||||
|
"project": proj_m.group(1) if proj_m else "shared",
|
||||||
|
"subproject": subproject,
|
||||||
|
"epic": epic,
|
||||||
|
"epic_label": epic_label,
|
||||||
|
"status": status_m.group(1) if status_m else "triage",
|
||||||
|
"type": type_m.group(1) if type_m else "feature",
|
||||||
|
"description": description,
|
||||||
|
"raw_markdown": chunk_clean,
|
||||||
|
"file_path": str(tasks_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
raise KeyError(f"Task {task_id_input} ({update_mod.format_task_id(target_num, prefix)}) was not found in {tasks_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Get detailed info and metadata for a task in tasks.md")
|
||||||
|
parser.add_argument("task_id", help="Task number or prefixed ID (e.g. 18, PREFIX-018)")
|
||||||
|
parser.add_argument("-j", "--json", action="store_true", help="Output details as JSON")
|
||||||
|
parser.add_argument("-d", "--description", "--description-only", dest="description", action="store_true", help="Output description and checklist")
|
||||||
|
parser.add_argument("--id", action="store_true", help="Output task ID only")
|
||||||
|
parser.add_argument("--title", action="store_true", help="Output task title only")
|
||||||
|
parser.add_argument("--project", action="store_true", help="Output task project only")
|
||||||
|
parser.add_argument("--subproject", action="store_true", help="Output task subproject only")
|
||||||
|
parser.add_argument("--epic", action="store_true", help="Output task epic value only")
|
||||||
|
parser.add_argument("--status", action="store_true", help="Output task status only")
|
||||||
|
parser.add_argument("--type", action="store_true", help="Output task type only")
|
||||||
|
parser.add_argument("-m", "--metadata-only", action="store_true", help="Output metadata header attributes only")
|
||||||
|
parser.add_argument("-f", "--file", default=None, help="Path to tasks.md (optional)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = get_task(args.task_id, tasks_file=args.file)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(task, indent=2))
|
||||||
|
elif args.metadata_only:
|
||||||
|
print(f"ID: {task['id']}")
|
||||||
|
print(f"Title: {task['title']}")
|
||||||
|
print(f"Project: {task['project']}")
|
||||||
|
if task.get("subproject"):
|
||||||
|
print(f"Subproject: {task['subproject']}")
|
||||||
|
if task.get("epic"):
|
||||||
|
print(f"Epic: {task['epic_label']}")
|
||||||
|
print(f"Status: {task['status']}")
|
||||||
|
print(f"Type: {task['type']}")
|
||||||
|
elif any([args.id, args.title, args.project, args.subproject, args.epic, args.status, args.type, args.description]):
|
||||||
|
output_parts = []
|
||||||
|
if args.id:
|
||||||
|
output_parts.append(task["id"])
|
||||||
|
if args.title:
|
||||||
|
output_parts.append(task["title"])
|
||||||
|
if args.project:
|
||||||
|
output_parts.append(task["project"])
|
||||||
|
if args.subproject:
|
||||||
|
output_parts.append(task["subproject"])
|
||||||
|
if args.epic:
|
||||||
|
output_parts.append(task["epic"])
|
||||||
|
if args.status:
|
||||||
|
output_parts.append(task["status"])
|
||||||
|
if args.type:
|
||||||
|
output_parts.append(task["type"])
|
||||||
|
if args.description:
|
||||||
|
output_parts.append(task["description"])
|
||||||
|
for part in output_parts:
|
||||||
|
print(part)
|
||||||
|
else:
|
||||||
|
print(task["raw_markdown"])
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"Error: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
296
scripts/get-tasks
Executable file
296
scripts/get-tasks
Executable file
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# get-tasks
|
||||||
|
#
|
||||||
|
# Lists tasks from docs/tasks.md sorted by ID.
|
||||||
|
# Supports --accept, --reject, and --sort [asc|desc] (defaults to descending).
|
||||||
|
# Defaults to Triage tasks, or Triage & Planning with -p / --planning.
|
||||||
|
# Uses filter-table if available, with a fast built-in fallback.
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
TASKS_FILE="${TASKS_FILE:-}"
|
||||||
|
if [[ -z "$TASKS_FILE" ]]; then
|
||||||
|
for cand in \
|
||||||
|
"$REPO_ROOT/docs/tasks.md" \
|
||||||
|
"$REPO_ROOT/docs/Tasks.md"; do
|
||||||
|
if [[ -f "$cand" ]]; then
|
||||||
|
TASKS_FILE="$cand"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$TASKS_FILE" ]]; then
|
||||||
|
TASKS_FILE="$REPO_ROOT/docs/tasks.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACCEPTS=()
|
||||||
|
REJECTS=()
|
||||||
|
MODE=""
|
||||||
|
SORT_DIR="desc"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
echo "usage: get-tasks [-h] [-p|--planning] [--all] [--accept PREDICATE] [--reject PREDICATE] [--sort {asc,desc}] [-s|--select COLS] [-f|--file PATH]"
|
||||||
|
echo ""
|
||||||
|
echo "Lists tasks from docs/tasks.md sorted by ID."
|
||||||
|
echo ""
|
||||||
|
echo "options:"
|
||||||
|
echo " -h, --help Show this help message and exit"
|
||||||
|
echo " -p, --planning Show tasks in Triage and Planning status"
|
||||||
|
echo " --all Show all tasks regardless of status"
|
||||||
|
echo " --accept PREDICATE Include rows matching predicate (e.g. 'Status:Fixed', 'Project:Engine', 'Epic:poc')"
|
||||||
|
echo " Epic filters accept frontmatter values or labels"
|
||||||
|
echo " --reject PREDICATE Exclude rows matching predicate (e.g. 'Status:Cancelled')"
|
||||||
|
echo " --sort [asc|desc] Sort order by task ID (default: desc)"
|
||||||
|
echo " -s, --select COLS Columns to display (e.g. 'ID,Title,Project,Epic,Status')"
|
||||||
|
echo " -f, --file PATH Path to tasks.md (overrides TASKS_FILE)"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-p|--planning)
|
||||||
|
MODE="planning"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
MODE="all"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-f|--file)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: $1 requires a file path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TASKS_FILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--accept)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --accept requires a predicate argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ACCEPTS+=("$2")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--accept=*)
|
||||||
|
ACCEPTS+=("${1#*=}")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--reject)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: $1 requires a predicate argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
REJECTS+=("$2")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--reject=*)
|
||||||
|
REJECTS+=("${1#*=}")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-s|--select)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: $1 requires a column list argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELECT_COLS="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--select=*)
|
||||||
|
SELECT_COLS="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--sort)
|
||||||
|
if [[ -n "${2:-}" && "$2" != -* ]]; then
|
||||||
|
SORT_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
SORT_DIR="desc"
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
--sort=*)
|
||||||
|
SORT_DIR="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unknown option '$1'" >&2
|
||||||
|
echo "Run 'get-tasks --help' for usage." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$SORT_DIR" = "asc" ]; then
|
||||||
|
SORT_FLAG="ID"
|
||||||
|
else
|
||||||
|
SORT_FLAG="-ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply default accept if no explicit accept was provided
|
||||||
|
if [ ${#ACCEPTS[@]} -eq 0 ]; then
|
||||||
|
if [ "$MODE" = "planning" ]; then
|
||||||
|
ACCEPTS+=("Status:Triage,Planning")
|
||||||
|
elif [ "$MODE" = "all" ]; then
|
||||||
|
: # No default filter
|
||||||
|
else
|
||||||
|
ACCEPTS+=("Status:Triage")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $((${#ACCEPTS[@]} + ${#REJECTS[@]})) -gt 0 ]; then
|
||||||
|
ACCEPT_COUNT=${#ACCEPTS[@]}
|
||||||
|
NORMALIZED_PREDICATES="$(python3 "$SCRIPT_DIR/lib/filter_predicates.py" "$TASKS_FILE" "${ACCEPTS[@]}" "${REJECTS[@]}")"
|
||||||
|
ACCEPTS=()
|
||||||
|
REJECTS=()
|
||||||
|
PREDICATE_INDEX=0
|
||||||
|
while IFS= read -r predicate; do
|
||||||
|
if [ "$PREDICATE_INDEX" -lt "$ACCEPT_COUNT" ]; then
|
||||||
|
ACCEPTS+=("$predicate")
|
||||||
|
else
|
||||||
|
REJECTS+=("$predicate")
|
||||||
|
fi
|
||||||
|
PREDICATE_INDEX=$((PREDICATE_INDEX + 1))
|
||||||
|
done <<< "$NORMALIZED_PREDICATES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v filter-table >/dev/null 2>&1; then
|
||||||
|
FILTER_ARGS=("$TASKS_FILE")
|
||||||
|
for acc in "${ACCEPTS[@]}"; do
|
||||||
|
FILTER_ARGS+=(--accept "$acc")
|
||||||
|
done
|
||||||
|
for rej in "${REJECTS[@]}"; do
|
||||||
|
FILTER_ARGS+=(--reject "$rej")
|
||||||
|
done
|
||||||
|
FILTER_ARGS+=(--sort "$SORT_FLAG")
|
||||||
|
|
||||||
|
if [ -n "$SELECT_COLS" ]; then
|
||||||
|
FILTER_ARGS+=(--select "$SELECT_COLS")
|
||||||
|
elif [ "$MODE" = "all" ]; then
|
||||||
|
ALL_COLS="ID,Title,Project"
|
||||||
|
if grep -q "| Subproject" "$TASKS_FILE"; then
|
||||||
|
ALL_COLS="$ALL_COLS,Subproject"
|
||||||
|
fi
|
||||||
|
if grep -q "| Epic" "$TASKS_FILE"; then
|
||||||
|
ALL_COLS="$ALL_COLS,Epic"
|
||||||
|
fi
|
||||||
|
FILTER_ARGS+=(--select "$ALL_COLS,Status")
|
||||||
|
else
|
||||||
|
FILTER_ARGS+=(--select "ID,Status,Title")
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec filter-table "${FILTER_ARGS[@]}"
|
||||||
|
else
|
||||||
|
# Python fallback implementation
|
||||||
|
python3 - "$TASKS_FILE" "$SORT_DIR" "$MODE" "${SELECT_COLS:-}" "${#ACCEPTS[@]}" "${ACCEPTS[@]}" "${REJECTS[@]}" <<'PY'
|
||||||
|
import sys, re
|
||||||
|
|
||||||
|
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
sort_dir, mode, select_cols_str = sys.argv[2:5]
|
||||||
|
accept_count = int(sys.argv[5])
|
||||||
|
accepts = sys.argv[6:6 + accept_count]
|
||||||
|
rejects = sys.argv[6 + accept_count:]
|
||||||
|
|
||||||
|
def parse_predicates(pred_list):
|
||||||
|
preds = []
|
||||||
|
for p in pred_list:
|
||||||
|
if ':' in p:
|
||||||
|
col, vals = p.split(':', 1)
|
||||||
|
allowed = {v.strip().lower() for v in vals.split(',') if v.strip()}
|
||||||
|
preds.append((col.strip().lower(), allowed))
|
||||||
|
return preds
|
||||||
|
|
||||||
|
parsed_accepts = parse_predicates(list(accepts) if isinstance(accepts, (list, tuple)) else [])
|
||||||
|
parsed_rejects = parse_predicates(list(rejects) if isinstance(rejects, (list, tuple)) else [])
|
||||||
|
|
||||||
|
in_table = False
|
||||||
|
headers = []
|
||||||
|
header_display = []
|
||||||
|
raw_rows = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith('| ID'):
|
||||||
|
in_table = True
|
||||||
|
header_display = [h.strip() for h in line.strip().split('|')[1:-1]]
|
||||||
|
headers = [h.lower() for h in header_display]
|
||||||
|
continue
|
||||||
|
if in_table:
|
||||||
|
if not line.strip() or line.startswith('## Detailed Tasks') or not line.startswith('|'):
|
||||||
|
break
|
||||||
|
parts = [p.strip() for p in line.strip().split('|')[1:-1]]
|
||||||
|
if len(parts) >= len(headers) and not parts[0].startswith(':-'):
|
||||||
|
row_dict = {headers[i]: parts[i] for i in range(len(headers))}
|
||||||
|
|
||||||
|
# Check accepts
|
||||||
|
accept_ok = True
|
||||||
|
for col, allowed in parsed_accepts:
|
||||||
|
val = row_dict.get(col, '').lower()
|
||||||
|
if val not in allowed:
|
||||||
|
accept_ok = False
|
||||||
|
break
|
||||||
|
if not accept_ok:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check rejects
|
||||||
|
reject_hit = False
|
||||||
|
for col, disallowed in parsed_rejects:
|
||||||
|
val = row_dict.get(col, '').lower()
|
||||||
|
if val in disallowed:
|
||||||
|
reject_hit = True
|
||||||
|
break
|
||||||
|
if reject_hit:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_rows.append(row_dict)
|
||||||
|
|
||||||
|
reverse_sort = (sort_dir != 'asc')
|
||||||
|
raw_rows.sort(
|
||||||
|
key=lambda r: int(re.search(r'\d+$', r.get('id', '0')).group(0)) if re.search(r'\d+$', r.get('id', '0')) else 0,
|
||||||
|
reverse=reverse_sort
|
||||||
|
)
|
||||||
|
|
||||||
|
if select_cols_str:
|
||||||
|
req_cols = [c.strip() for c in select_cols_str.split(',') if c.strip()]
|
||||||
|
out_headers = [c for c in req_cols if c.lower() in headers]
|
||||||
|
else:
|
||||||
|
if mode == 'all':
|
||||||
|
out_headers = ['ID', 'Title', 'Project']
|
||||||
|
if 'subproject' in headers:
|
||||||
|
out_headers.append('Subproject')
|
||||||
|
if 'epic' in headers:
|
||||||
|
out_headers.append('Epic')
|
||||||
|
out_headers.append('Status')
|
||||||
|
else:
|
||||||
|
out_headers = ['ID', 'Title']
|
||||||
|
|
||||||
|
col_keys = [c.lower() for c in out_headers]
|
||||||
|
col_widths = [len(h) for h in out_headers]
|
||||||
|
|
||||||
|
formatted_rows = []
|
||||||
|
for r in raw_rows:
|
||||||
|
row_vals = []
|
||||||
|
for k in col_keys:
|
||||||
|
val = r.get(k, '')
|
||||||
|
if k == 'title' and not select_cols_str and mode != 'all':
|
||||||
|
clean_t = val.strip('*').strip('_').strip()
|
||||||
|
st = r.get('status', '').lower()
|
||||||
|
val = f'**{clean_t}**' if st == 'pending' else f'*{clean_t}*' if st in ('triage', 'planning') else clean_t
|
||||||
|
row_vals.append(val)
|
||||||
|
formatted_rows.append(row_vals)
|
||||||
|
for idx, v in enumerate(row_vals):
|
||||||
|
col_widths[idx] = max(col_widths[idx], len(v))
|
||||||
|
|
||||||
|
hdr_line = '| ' + ' | '.join(f'{out_headers[i]:<{col_widths[i]}}' for i in range(len(out_headers))) + ' |'
|
||||||
|
sep_line = '| ' + ' | '.join(':' + '-' * (col_widths[i] - 1) for i in range(len(out_headers))) + ' |'
|
||||||
|
print(hdr_line)
|
||||||
|
print(sep_line)
|
||||||
|
for row in formatted_rows:
|
||||||
|
print('| ' + ' | '.join(f'{row[i]:<{col_widths[i]}}' for i in range(len(out_headers))) + ' |')
|
||||||
|
PY
|
||||||
|
fi
|
||||||
1
scripts/lib/__init__.py
Normal file
1
scripts/lib/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Shared automation library for project task tooling."""
|
||||||
52
scripts/lib/agents/__init__.py
Normal file
52
scripts/lib/agents/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from .base import BaseAgent
|
||||||
|
from .antigravity import AntigravityAgent
|
||||||
|
from .hermes import HermesAgent
|
||||||
|
|
||||||
|
_REGISTERED_AGENTS: List[BaseAgent] = [
|
||||||
|
AntigravityAgent(),
|
||||||
|
HermesAgent(),
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOOKUP_MAP: Dict[str, BaseAgent] = {}
|
||||||
|
for agent in _REGISTERED_AGENTS:
|
||||||
|
_LOOKUP_MAP[agent.name.lower()] = agent
|
||||||
|
for alias in agent.aliases:
|
||||||
|
_LOOKUP_MAP[alias.lower()] = agent
|
||||||
|
|
||||||
|
|
||||||
|
def list_agents() -> List[BaseAgent]:
|
||||||
|
"""Return list of all registered agent instances."""
|
||||||
|
return list(_REGISTERED_AGENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent(name_or_alias: str) -> Optional[BaseAgent]:
|
||||||
|
"""Look up an agent by its primary name or any alias."""
|
||||||
|
return _LOOKUP_MAP.get(name_or_alias.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_agent() -> BaseAgent:
|
||||||
|
"""
|
||||||
|
Resolve the default agent to use:
|
||||||
|
1. Check TASK_AGENT environment variable.
|
||||||
|
2. Fall back to the first available agent CLI found on PATH.
|
||||||
|
3. If none installed, return the first registered agent (which will report not found on launch).
|
||||||
|
"""
|
||||||
|
env_agent = os.environ.get("TASK_AGENT")
|
||||||
|
if env_agent:
|
||||||
|
matched = get_agent(env_agent)
|
||||||
|
if matched:
|
||||||
|
return matched
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown agent specified in TASK_AGENT='{env_agent}'. "
|
||||||
|
f"Available agents: {', '.join([a.name for a in _REGISTERED_AGENTS])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-detect available agent
|
||||||
|
for agent in _REGISTERED_AGENTS:
|
||||||
|
if agent.is_available():
|
||||||
|
return agent
|
||||||
|
|
||||||
|
return _REGISTERED_AGENTS[0]
|
||||||
25
scripts/lib/agents/antigravity.py
Normal file
25
scripts/lib/agents/antigravity.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import List, Optional
|
||||||
|
from .base import BaseAgent
|
||||||
|
|
||||||
|
|
||||||
|
class AntigravityAgent(BaseAgent):
|
||||||
|
"""Adapter for Google Antigravity CLI (agy)."""
|
||||||
|
|
||||||
|
name = "antigravity"
|
||||||
|
aliases = ["agy"]
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return shutil.which("agy") is not None
|
||||||
|
|
||||||
|
def launch(self, prompt: str, extra_args: Optional[List[str]] = None) -> None:
|
||||||
|
bin_path = shutil.which("agy")
|
||||||
|
if not bin_path:
|
||||||
|
raise FileNotFoundError("Antigravity CLI ('agy') is not found on PATH.")
|
||||||
|
|
||||||
|
cmd = [bin_path, "-i", prompt]
|
||||||
|
if extra_args:
|
||||||
|
cmd.extend(extra_args)
|
||||||
|
|
||||||
|
os.execvp(bin_path, cmd)
|
||||||
22
scripts/lib/agents/base.py
Normal file
22
scripts/lib/agents/base.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAgent(ABC):
|
||||||
|
"""Abstract base class for AI CLI agents."""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
aliases: List[str] = []
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if the agent CLI binary is installed and reachable in PATH."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def launch(self, prompt: str, extra_args: Optional[List[str]] = None) -> None:
|
||||||
|
"""Launch the agent interactively with the pre-seeded prompt."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.__class__.__name__} name={self.name}>"
|
||||||
25
scripts/lib/agents/hermes.py
Normal file
25
scripts/lib/agents/hermes.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import List, Optional
|
||||||
|
from .base import BaseAgent
|
||||||
|
|
||||||
|
|
||||||
|
class HermesAgent(BaseAgent):
|
||||||
|
"""Adapter for Hermes Agent CLI."""
|
||||||
|
|
||||||
|
name = "hermes"
|
||||||
|
aliases = ["hermes-agent"]
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return shutil.which("hermes") is not None
|
||||||
|
|
||||||
|
def launch(self, prompt: str, extra_args: Optional[List[str]] = None) -> None:
|
||||||
|
bin_path = shutil.which("hermes")
|
||||||
|
if not bin_path:
|
||||||
|
raise FileNotFoundError("Hermes CLI ('hermes') is not found on PATH.")
|
||||||
|
|
||||||
|
cmd = [bin_path, "chat", "-q", prompt]
|
||||||
|
if extra_args:
|
||||||
|
cmd.extend(extra_args)
|
||||||
|
|
||||||
|
os.execvp(bin_path, cmd)
|
||||||
36
scripts/lib/filter_predicates.py
Normal file
36
scripts/lib/filter_predicates.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import runpy
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_predicate(predicate, epics):
|
||||||
|
column, separator, values = predicate.partition(":")
|
||||||
|
if not separator or column.strip().lower() != "epic":
|
||||||
|
return predicate
|
||||||
|
labels = {}
|
||||||
|
for epic in epics:
|
||||||
|
value = epic.get("value", "").strip()
|
||||||
|
label = epic.get("label", "").strip() or value.replace("-", " ").replace("_", " ").title()
|
||||||
|
if value:
|
||||||
|
labels[value.lower()] = label
|
||||||
|
labels[label.lower()] = label
|
||||||
|
normalized = [labels.get(value.strip().lower(), value.strip()) for value in values.split(",")]
|
||||||
|
return f"Epic:{','.join(normalized)}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
update = runpy.run_path(str(Path(__file__).resolve().parents[1] / "update-tasks"))
|
||||||
|
text = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
epics = update["read_frontmatter"](text).get("epics", [])
|
||||||
|
for predicate in sys.argv[2:]:
|
||||||
|
print(normalize_predicate(predicate, epics))
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
print(f"Error normalizing task filters: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
163
scripts/lib/task_context.py
Normal file
163
scripts/lib/task_context.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import argparse
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from agents import BaseAgent, get_agent, get_default_agent, list_agents
|
||||||
|
|
||||||
|
|
||||||
|
def load_project_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
||||||
|
path = config_path if config_path is not None else Path(__file__).resolve().parents[2] / "project-config.json"
|
||||||
|
try:
|
||||||
|
config = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"Cannot read project configuration {path}: {error}") from error
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError(f"Project configuration {path} must be a JSON object")
|
||||||
|
for field in ("name", "description"):
|
||||||
|
if not isinstance(config.get(field), str) or not config[field].strip():
|
||||||
|
raise ValueError(f"Project configuration {path} requires a non-empty '{field}' string")
|
||||||
|
commands = config.get("commands")
|
||||||
|
if not isinstance(commands, dict) or any(
|
||||||
|
not isinstance(name, str) or not name.strip()
|
||||||
|
or not isinstance(command, str) or not command.strip()
|
||||||
|
for name, command in commands.items()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Project configuration {path} requires 'commands' to map names to non-empty command strings")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def load_get_task_module():
|
||||||
|
"""Dynamically load scripts/get-task."""
|
||||||
|
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
get_task_script = repo_root / "scripts" / "get-task"
|
||||||
|
if not get_task_script.exists():
|
||||||
|
raise FileNotFoundError(f"Could not find get-task script at {get_task_script}")
|
||||||
|
loader = importlib.machinery.SourceFileLoader("get_task_mod", str(get_task_script))
|
||||||
|
spec = importlib.util.spec_from_loader("get_task_mod", loader)
|
||||||
|
if spec is None:
|
||||||
|
raise ImportError(f"Could not load spec for {get_task_script}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_task(task_id: str, tasks_file: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Retrieve task dictionary using get_task."""
|
||||||
|
mod = load_get_task_module()
|
||||||
|
return mod.get_task(task_id, tasks_file=tasks_file)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_task_status(task: Dict[str, Any], allowed_statuses: List[str], command_name: str) -> None:
|
||||||
|
"""Validate that the task status matches one of the allowed statuses, otherwise error and exit."""
|
||||||
|
current_status = task.get("status", "").strip().lower()
|
||||||
|
allowed_lower = [s.lower() for s in allowed_statuses]
|
||||||
|
|
||||||
|
if current_status not in allowed_lower:
|
||||||
|
if len(allowed_statuses) == 1:
|
||||||
|
req_str = f"'{allowed_statuses[0]}'"
|
||||||
|
else:
|
||||||
|
req_str = " or ".join([f"'{s}'" for s in allowed_statuses])
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Error: Task {task['id']} is in '{task['status']}' status. "
|
||||||
|
f"{command_name.capitalize()} requires status to be {req_str}.\n"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow_prompt(
|
||||||
|
task: Dict[str, Any],
|
||||||
|
workflow_name: str,
|
||||||
|
workflow_objective: str,
|
||||||
|
extra_context: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Construct a standardized, high-fidelity prompt for an AI agent."""
|
||||||
|
config = load_project_config()
|
||||||
|
commands = "\n".join(f"- {name}: `{command}`" for name, command in config["commands"].items())
|
||||||
|
if not commands:
|
||||||
|
commands = "- No commands configured. Agree on relevant verification criteria with the user."
|
||||||
|
desc = task["description"].strip() if task.get("description") else "(No description provided yet)"
|
||||||
|
subproj_line = f"- Subproject: {task['subproject']}\n" if task.get("subproject") else ""
|
||||||
|
epic_line = f"- Epic: {task.get('epic_label', task['epic'])} ({task['epic']})\n" if task.get("epic") else ""
|
||||||
|
epic_arg = f" --epic={task['epic']}" if task.get("epic") else ""
|
||||||
|
|
||||||
|
prompt = f"""You are {workflow_name} task {task['id']} for {config['name']}.
|
||||||
|
|
||||||
|
### Project:
|
||||||
|
{config['description']}
|
||||||
|
|
||||||
|
### Target Task:
|
||||||
|
- ID: {task['id']}
|
||||||
|
- Title: {task['title']}
|
||||||
|
- Project: {task['project']}
|
||||||
|
{subproj_line}{epic_line}- Current Status: {task['status']}
|
||||||
|
- Type: {task['type']}
|
||||||
|
|
||||||
|
### Current Description & Checklist:
|
||||||
|
{desc}
|
||||||
|
|
||||||
|
### Repository Context & Guidelines:
|
||||||
|
1. SSoT & Task Management (AGENTS.md):
|
||||||
|
- You MUST use `./scripts/update-task {task['id']} ...` to update task metadata, description, title, or checklist items.
|
||||||
|
- If this task needs to be split into subtasks, use `./scripts/add-task "Subtask Title" --project={task['project']} --type={task['type']}{epic_arg}`.
|
||||||
|
- Do NOT edit docs/tasks.md manually.
|
||||||
|
2. Project Context:
|
||||||
|
- Read AGENTS.md, README.md, and relevant project documents before starting.
|
||||||
|
- Do not assume this is software or prescribe a language, platform, or architecture.
|
||||||
|
- Use the project's agreed verification criteria, including human review when appropriate.
|
||||||
|
|
||||||
|
### Optional Project Commands (guidance, not automatically executed):
|
||||||
|
{commands}
|
||||||
|
|
||||||
|
3. Objectives for this session:
|
||||||
|
{workflow_objective}
|
||||||
|
"""
|
||||||
|
if extra_context:
|
||||||
|
prompt += f"\n### Additional Context:\n{extra_context}\n"
|
||||||
|
|
||||||
|
prompt += "\nBegin by reviewing the task, inspecting the relevant project materials, and collaborating interactively with the user.\n"
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def add_common_agent_arguments(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""Register reusable CLI flags for agent selection and tasks.md path."""
|
||||||
|
registered = list_agents()
|
||||||
|
agent_names = [a.name for a in registered]
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--agent",
|
||||||
|
choices=agent_names,
|
||||||
|
default=None,
|
||||||
|
help=f"AI agent CLI to use ({', '.join(agent_names)}). Defaults to $TASK_AGENT or first available.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--hermes", action="store_true", help="Shortcut for --agent hermes")
|
||||||
|
parser.add_argument("--agy", action="store_true", help="Shortcut for --agent antigravity")
|
||||||
|
parser.add_argument("-f", "--file", default=None, help="Path to tasks.md (optional)")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_selected_agent(args: argparse.Namespace) -> BaseAgent:
|
||||||
|
"""Resolve which agent was requested via flags or environment."""
|
||||||
|
if getattr(args, "hermes", False):
|
||||||
|
agent = get_agent("hermes")
|
||||||
|
elif getattr(args, "agy", False):
|
||||||
|
agent = get_agent("antigravity")
|
||||||
|
elif getattr(args, "agent", None):
|
||||||
|
agent = get_agent(args.agent)
|
||||||
|
else:
|
||||||
|
agent = get_default_agent()
|
||||||
|
|
||||||
|
if not agent:
|
||||||
|
sys.stderr.write("Error: Could not resolve requested agent.\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not agent.is_available():
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Error: Selected agent '{agent.name}' is not available on PATH. "
|
||||||
|
f"Please verify installation.\n"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return agent
|
||||||
63
scripts/research
Executable file
63
scripts/research
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
research
|
||||||
|
|
||||||
|
Researches a task from docs/tasks.md using an AI CLI agent (Antigravity or Hermes).
|
||||||
|
Validates that the task is in 'research' status, prepares task and project context,
|
||||||
|
and launches the agent interactively.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/research 037
|
||||||
|
./scripts/research EX-037 --hermes
|
||||||
|
./scripts/research 037 --agy
|
||||||
|
./scripts/research --help
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure scripts/lib is importable
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||||
|
|
||||||
|
from task_context import (
|
||||||
|
fetch_task,
|
||||||
|
assert_task_status,
|
||||||
|
build_workflow_prompt,
|
||||||
|
add_common_agent_arguments,
|
||||||
|
resolve_selected_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
RESEARCH_OBJECTIVES = """- Investigate the requirements, background, and evidence needed for this task.
|
||||||
|
- Inspect existing project materials to identify gaps or challenges.
|
||||||
|
- Consult relevant sources when needed and record citations.
|
||||||
|
- Collaborate interactively with the user to explore trade-offs and possible approaches.
|
||||||
|
- Document research findings, recommendations, and proposed next steps.
|
||||||
|
- When research is complete, update the task description/notes via `./scripts/update-task {task_id}` and determine if it's ready to transition to 'planning' or 'ready'."""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Research a task in docs/tasks.md using an AI CLI agent. Requires status to be 'research'."
|
||||||
|
)
|
||||||
|
parser.add_argument("task_id", help="Task number or prefixed ID (e.g. 37, PREFIX-037)")
|
||||||
|
add_common_agent_arguments(parser)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
agent = resolve_selected_agent(args)
|
||||||
|
task = fetch_task(args.task_id, tasks_file=args.file)
|
||||||
|
assert_task_status(task, ["research"], command_name="research")
|
||||||
|
|
||||||
|
prompt = build_workflow_prompt(
|
||||||
|
task=task,
|
||||||
|
workflow_name="conducting technical research on",
|
||||||
|
workflow_objective=RESEARCH_OBJECTIVES.format(task_id=task["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Launching {agent.name.capitalize()} ({agent.name}) to research {task['id']}: {task['title']}...")
|
||||||
|
agent.launch(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
407
scripts/tests/test_task_scripts.py
Normal file
407
scripts/tests/test_task_scripts.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def tracker(prefix=None, epics=True, number=1):
|
||||||
|
prefix_line = f"task-id-prefix: '{prefix}'\n" if prefix is not None else ""
|
||||||
|
epic_frontmatter = """epics:
|
||||||
|
- value: 'poc'
|
||||||
|
label: 'Proof of Concept'
|
||||||
|
description: 'Validate the architecture.'
|
||||||
|
- value: 'release'
|
||||||
|
label: "Team's Release"
|
||||||
|
description: 'Ship the product.'
|
||||||
|
""" if epics else ""
|
||||||
|
anchor_prefix = prefix.lower() if prefix else "ex"
|
||||||
|
return f"""---
|
||||||
|
{prefix_line}projects:
|
||||||
|
- value: 'shared'
|
||||||
|
label: 'Shared'
|
||||||
|
subprojects:
|
||||||
|
- value: 'engine'
|
||||||
|
label: 'Engine'
|
||||||
|
{epic_frontmatter}task-statuses:
|
||||||
|
- value: 'pending'
|
||||||
|
label: 'Pending'
|
||||||
|
- value: 'in_progress'
|
||||||
|
label: 'In Progress'
|
||||||
|
task-types:
|
||||||
|
- value: 'feature'
|
||||||
|
label: 'Feature'
|
||||||
|
---
|
||||||
|
# Tracker
|
||||||
|
|
||||||
|
| ID | Title | Project | Status | Type |
|
||||||
|
|:---|:------|:--------|:-------|:-----|
|
||||||
|
| {anchor_prefix.upper()}-{number:03d} | Original | Shared | Pending | Feature |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Tasks
|
||||||
|
|
||||||
|
<a id="{anchor_prefix}-{number:03d}" class="task" data-project="shared" data-subproject="engine" data-status="pending" data-task-type="feature"></a>
|
||||||
|
### Original
|
||||||
|
**ID:** {anchor_prefix.upper()}-{number:03d}
|
||||||
|
**Project:** Shared
|
||||||
|
**Subproject:** Engine
|
||||||
|
**Status:** Pending
|
||||||
|
**Type:** Feature
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Keep this description.
|
||||||
|
- [ ] First requirement
|
||||||
|
- [x] Second requirement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
See [original](#{anchor_prefix}-{number:03d}).
|
||||||
|
|
||||||
|
#### Projects (Rendered from frontmatter projects)
|
||||||
|
Placeholder
|
||||||
|
<!-- Table Sort Injection -->
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TaskScriptsTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.path = Path(self.temp.name) / "task tracker's file.md"
|
||||||
|
self.path.write_text(tracker(), encoding="utf-8")
|
||||||
|
self.env = os.environ.copy()
|
||||||
|
self.env.pop("TASK_ID_PREFIX", None)
|
||||||
|
self.env.pop("TASKS_FILE", None)
|
||||||
|
|
||||||
|
def run_cli(self, command, *args, success=True, env=None):
|
||||||
|
executable = ["bash"] if command == "get-tasks" else [sys.executable]
|
||||||
|
result = subprocess.run(
|
||||||
|
[*executable, str(SCRIPTS / command), *args, "-f", str(self.path)],
|
||||||
|
env=self.env if env is None else env,
|
||||||
|
text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
else:
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_task(self, task_id="1"):
|
||||||
|
return json.loads(self.run_cli("get-task", task_id, "--json").stdout)
|
||||||
|
|
||||||
|
def test_epic_add_update_clear_and_preserve(self):
|
||||||
|
self.run_cli("add-task", "New task", "--epic", "Proof of Concept", "--subproject", "engine")
|
||||||
|
task = self.get_task("EX-002")
|
||||||
|
self.assertEqual((task["epic"], task["epic_label"]), ("poc", "Proof of Concept"))
|
||||||
|
self.assertEqual(task["subproject"], "engine")
|
||||||
|
self.assertIn('data-epic="poc"', task["raw_markdown"])
|
||||||
|
self.assertIn("**Epic:** Proof of Concept", task["raw_markdown"])
|
||||||
|
self.assertEqual(self.run_cli("get-task", "2", "--epic").stdout.strip(), "poc")
|
||||||
|
self.assertIn("Epic: Proof of Concept", self.run_cli("get-task", "2", "--metadata-only").stdout)
|
||||||
|
self.run_cli("update-task", "1", "--epic", "poc", "--status", "in_progress", "--check", "1")
|
||||||
|
self.run_cli("update-task", "1", "--title", "Renamed")
|
||||||
|
original = self.get_task()
|
||||||
|
self.assertEqual(original["epic"], "poc")
|
||||||
|
self.assertIn("Keep this description.", original["description"])
|
||||||
|
self.assertIn("- [x] First requirement", original["description"])
|
||||||
|
self.run_cli("update-task", "1", "--epic", "Team's Release", "--no-sync")
|
||||||
|
self.assertEqual(self.get_task()["epic"], "release")
|
||||||
|
self.run_cli("update-tasks")
|
||||||
|
synced = self.path.read_text()
|
||||||
|
self.run_cli("update-tasks")
|
||||||
|
self.assertEqual(synced, self.path.read_text())
|
||||||
|
self.assertIn("#### Epics (Rendered from frontmatter epics)", synced)
|
||||||
|
self.run_cli("update-task", "1", "--clear-epic")
|
||||||
|
self.assertEqual(self.get_task()["epic"], "")
|
||||||
|
self.assertNotIn("**Epic:**", self.get_task()["raw_markdown"])
|
||||||
|
self.run_cli("update-task", "2", "--epic", "")
|
||||||
|
self.assertEqual(self.get_task("2")["epic"], "")
|
||||||
|
|
||||||
|
def test_invalid_epic_does_not_write(self):
|
||||||
|
before = self.path.read_text()
|
||||||
|
for command, args in (
|
||||||
|
("add-task", ("Invalid", "--epic", "missing")),
|
||||||
|
("update-task", ("1", "--epic", "missing")),
|
||||||
|
):
|
||||||
|
with self.subTest(command=command):
|
||||||
|
result = self.run_cli(command, *args, success=False)
|
||||||
|
self.assertIn("Unknown epic", result.stderr)
|
||||||
|
self.assertEqual(before, self.path.read_text())
|
||||||
|
|
||||||
|
def test_frontmatter_prefix_with_digits_and_large_ids(self):
|
||||||
|
self.path.write_text(tracker(prefix="ab2-work", number=999))
|
||||||
|
self.run_cli("add-task", "Thousandth", "--epic", "release")
|
||||||
|
task = self.get_task("ab2-work-1000")
|
||||||
|
self.assertEqual(task["number"], 1000)
|
||||||
|
self.assertEqual(task["id"], "AB2-WORK-1000")
|
||||||
|
self.assertEqual(task["anchor_id"], "ab2-work-1000")
|
||||||
|
self.assertEqual(self.get_task("999")["anchor_id"], "ab2-work-0999")
|
||||||
|
self.assertEqual(self.get_task("999")["id"], "AB2-WORK-0999")
|
||||||
|
self.assertEqual(self.get_task("AB2-WORK-00999")["number"], 999)
|
||||||
|
self.run_cli("update-task", "999", "--title", "Still padded", "--no-sync")
|
||||||
|
self.assertIn("**ID:** AB2-WORK-0999", self.get_task("999")["raw_markdown"])
|
||||||
|
self.run_cli("update-task", "AB2-WORK-1000", "--status", "pending")
|
||||||
|
self.assertEqual(self.get_task("1000")["status"], "pending")
|
||||||
|
env = {**self.env, "PATH": "/usr/bin:/bin"}
|
||||||
|
output = self.run_cli("get-tasks", "--all", env=env).stdout
|
||||||
|
self.assertLess(output.index("AB2-WORK-1000"), output.index("AB2-WORK-0999"))
|
||||||
|
self.run_cli("get-task", "WRONG-1000", success=False)
|
||||||
|
for invalid in ("abc", "1junk", "something 1000"):
|
||||||
|
self.run_cli("get-task", invalid, success=False)
|
||||||
|
|
||||||
|
def test_environment_override_migrates_ids_and_links(self):
|
||||||
|
self.path.write_text(
|
||||||
|
tracker(prefix="doc")
|
||||||
|
+ '\n[reference]: #doc-001\n<a href="#doc-001">Task</a>\n'
|
||||||
|
+ '[external](https://example.com/#doc-001)\n'
|
||||||
|
)
|
||||||
|
env = {**self.env, "TASK_ID_PREFIX": "env"}
|
||||||
|
self.run_cli("update-tasks", env=env)
|
||||||
|
text = self.path.read_text()
|
||||||
|
self.assertIn('id="env-001"', text)
|
||||||
|
self.assertIn("**ID:** ENV-001", text)
|
||||||
|
self.assertIn("[original](#env-001)", text)
|
||||||
|
self.assertIn("[reference]: #env-001", text)
|
||||||
|
self.assertIn('href="#env-001"', text)
|
||||||
|
self.assertIn("[external](https://example.com/#doc-001)", text)
|
||||||
|
self.run_cli("add-task", "Next", env=env)
|
||||||
|
task = json.loads(self.run_cli("get-task", "ENV-002", "--json", env=env).stdout)
|
||||||
|
self.assertEqual(task["id"], "ENV-002")
|
||||||
|
|
||||||
|
def test_default_prefix_without_epics_and_environment_file(self):
|
||||||
|
self.path.write_text(tracker(epics=False))
|
||||||
|
self.run_cli("update-tasks")
|
||||||
|
self.assertEqual(self.get_task()["id"], "EX-001")
|
||||||
|
self.assertEqual(self.get_task()["epic"], "")
|
||||||
|
self.assertNotIn("| Epic", self.path.read_text())
|
||||||
|
env = {**self.env, "TASKS_FILE": str(self.path)}
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPTS / "get-task"), "1", "--id"],
|
||||||
|
env=env, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.strip(), "EX-001")
|
||||||
|
|
||||||
|
def test_invalid_prefix_and_duplicate_numbers(self):
|
||||||
|
for prefix in ("", "1BAD", "BAD SPACE", "BAD/"):
|
||||||
|
with self.subTest(prefix=prefix):
|
||||||
|
before = self.path.read_text()
|
||||||
|
self.run_cli("update-tasks", env={**self.env, "TASK_ID_PREFIX": prefix}, success=False)
|
||||||
|
self.assertEqual(before, self.path.read_text())
|
||||||
|
text = self.path.read_text()
|
||||||
|
chunk = text.split("## Detailed Tasks\n", 1)[1].split("\n---\n\n## Notes", 1)[0]
|
||||||
|
self.path.write_text(text.replace("\n---\n\n## Notes", chunk + "\n---\n\n## Notes"))
|
||||||
|
self.assertIn("Duplicate numeric task IDs", self.run_cli("update-tasks", success=False).stderr)
|
||||||
|
|
||||||
|
def test_summary_table_conflict_handling(self):
|
||||||
|
text = self.path.read_text()
|
||||||
|
# Inject conflict markers surrounding and within the summary table
|
||||||
|
conflicted = text.replace(
|
||||||
|
"| ID | Title |",
|
||||||
|
"<<<<<<< HEAD\n| ID | Title |\n=======\n| ID | Title | Project |\n>>>>>>> branch",
|
||||||
|
)
|
||||||
|
self.path.write_text(conflicted)
|
||||||
|
self.run_cli("update-tasks")
|
||||||
|
updated = self.path.read_text()
|
||||||
|
self.assertNotIn("<<<<<<<", updated)
|
||||||
|
self.assertNotIn("=======", updated)
|
||||||
|
self.assertNotIn(">>>>>>>", updated)
|
||||||
|
self.assertIn("EX-001", updated)
|
||||||
|
self.assertIn("Original", updated)
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_epic_filters_and_selection_fallback(self):
|
||||||
|
self.run_cli("update-task", "1", "--epic", "release")
|
||||||
|
self.run_cli("add-task", "Second", "--epic", "poc")
|
||||||
|
env = {**self.env, "PATH": "/usr/bin:/bin"}
|
||||||
|
output = self.run_cli("get-tasks", "--all", "--sort", "asc", env=env).stdout
|
||||||
|
self.assertIn("Epic", output)
|
||||||
|
self.assertLess(output.index("EX-001"), output.index("EX-002"))
|
||||||
|
output = self.run_cli(
|
||||||
|
"get-tasks", "--all", "--accept", "Epic:Team's Release", "--select", "ID,Epic", env=env,
|
||||||
|
).stdout
|
||||||
|
self.assertIn("EX-001", output)
|
||||||
|
self.assertNotIn("EX-002", output)
|
||||||
|
self.assertNotIn("Title", output)
|
||||||
|
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:Proof of Concept", env=env).stdout
|
||||||
|
self.assertNotIn("EX-002", output)
|
||||||
|
self.assertIn("EX-001", output)
|
||||||
|
for value in ("poc", "POC", "Proof of Concept"):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
output = self.run_cli("get-tasks", "--accept", f"Epic:{value}", env=env).stdout
|
||||||
|
self.assertIn("EX-002", output)
|
||||||
|
self.assertNotIn("EX-001", output)
|
||||||
|
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:poc", env=env).stdout
|
||||||
|
self.assertNotIn("EX-002", output)
|
||||||
|
self.assertIn("EX-001", output)
|
||||||
|
output = self.run_cli(
|
||||||
|
"get-tasks", "--accept", "Epic:poc,release", "--accept", "Status:Triage", env=env,
|
||||||
|
).stdout
|
||||||
|
self.assertIn("EX-002", output)
|
||||||
|
self.assertNotIn("EX-001", output)
|
||||||
|
self.assertNotIn("EX-001", self.run_cli("get-tasks", "--accept", "Epic:missing", env=env).stdout)
|
||||||
|
|
||||||
|
def test_listing_external_filter_table_selects_epic(self):
|
||||||
|
self.run_cli("update-tasks")
|
||||||
|
stub = Path(self.temp.name) / "filter-table"
|
||||||
|
stub.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n")
|
||||||
|
stub.chmod(0o755)
|
||||||
|
env = {**self.env, "PATH": f"{self.temp.name}:/usr/bin:/bin"}
|
||||||
|
output = self.run_cli("get-tasks", "--all", "--accept", "Epic:Proof of Concept", env=env).stdout
|
||||||
|
self.assertIn("ID,Title,Project,Subproject,Epic,Status", output)
|
||||||
|
self.assertIn("Epic:Proof of Concept", output)
|
||||||
|
output = self.run_cli(
|
||||||
|
"get-tasks", "--all", "--accept", "epic:POC,release", "--reject", "Epic:release",
|
||||||
|
"--accept", "Status:Pending", env=env,
|
||||||
|
).stdout
|
||||||
|
self.assertIn("Epic:Proof of Concept,Team's Release", output)
|
||||||
|
self.assertIn("Epic:Team's Release", output)
|
||||||
|
self.assertIn("Status:Pending", output)
|
||||||
|
self.assertNotIn("Epic:release", output)
|
||||||
|
|
||||||
|
def test_filter_frontmatter_errors_are_reported(self):
|
||||||
|
self.path.write_text("No frontmatter\n")
|
||||||
|
env = {**self.env, "PATH": "/usr/bin:/bin"}
|
||||||
|
result = self.run_cli("get-tasks", "--accept", "Epic:poc", env=env, success=False)
|
||||||
|
self.assertIn("Error normalizing task filters: YAML frontmatter not found", result.stderr)
|
||||||
|
|
||||||
|
def test_workflow_surfaces(self):
|
||||||
|
self.run_cli("update-task", "1", "--epic", "poc")
|
||||||
|
output = self.run_cli("work-on-task", "1", "--start").stdout
|
||||||
|
self.assertIn("Proof of Concept", output)
|
||||||
|
self.assertEqual(self.get_task()["status"], "in_progress")
|
||||||
|
with patch.dict(os.environ, self.env, clear=True):
|
||||||
|
sys.path.insert(0, str(SCRIPTS / "lib"))
|
||||||
|
try:
|
||||||
|
import task_context
|
||||||
|
task = task_context.fetch_task("1", tasks_file=str(self.path))
|
||||||
|
prompt = task_context.build_workflow_prompt(task, "testing", "Verify the task")
|
||||||
|
finally:
|
||||||
|
sys.path.pop(0)
|
||||||
|
self.assertIn("- Epic: Proof of Concept (poc)", prompt)
|
||||||
|
self.assertIn("--epic=poc", prompt)
|
||||||
|
|
||||||
|
def test_ready_task_can_be_started(self):
|
||||||
|
self.run_cli("update-task", "1", "--status", "ready")
|
||||||
|
self.run_cli("work-on-task", "1", "--start")
|
||||||
|
self.assertEqual(self.get_task()["status"], "in_progress")
|
||||||
|
self.run_cli("work-on-task", "1")
|
||||||
|
|
||||||
|
def test_hyphenated_in_progress_is_already_started(self):
|
||||||
|
self.run_cli("update-task", "1", "--status", "in-progress")
|
||||||
|
before = self.path.read_text()
|
||||||
|
self.run_cli("work-on-task", "1", "--start")
|
||||||
|
self.assertEqual(before, self.path.read_text())
|
||||||
|
|
||||||
|
def test_repository_sample_and_config_are_consistent(self):
|
||||||
|
config = json.loads((SCRIPTS.parent / "project-config.json").read_text())
|
||||||
|
sample = SCRIPTS.parent / "docs" / "tasks.md"
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPTS / "get-task"), "1", "-f", str(sample), "--json"],
|
||||||
|
env=self.env, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
task = json.loads(result.stdout)
|
||||||
|
self.assertIsInstance(config["name"], str)
|
||||||
|
sys.path.insert(0, str(SCRIPTS / "lib"))
|
||||||
|
try:
|
||||||
|
import task_context
|
||||||
|
update = task_context.load_get_task_module().load_update_tasks_module()
|
||||||
|
finally:
|
||||||
|
sys.path.pop(0)
|
||||||
|
frontmatter = update.read_frontmatter(sample.read_text())
|
||||||
|
project = next(item for item in frontmatter["projects"] if item["value"] == task["project"])
|
||||||
|
self.assertIn(f"**Project:** {project['label']}", task["raw_markdown"])
|
||||||
|
self.assertEqual(task["id"].rsplit("-", 1)[0], update.get_task_id_prefix(frontmatter))
|
||||||
|
copy = Path(self.temp.name) / "sample.md"
|
||||||
|
copy.write_text(sample.read_text())
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPTS / "update-tasks"), "-f", str(copy)],
|
||||||
|
env=self.env, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(sample.read_text(), copy.read_text())
|
||||||
|
|
||||||
|
def test_environment_preserves_external_tracker(self):
|
||||||
|
env = {**self.env, "TASKS_FILE": str(self.path)}
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", "-c", 'source "$1" >/dev/null; printf "%s\\n" "$TASKS_FILE"; command -v get-task',
|
||||||
|
"bash", str(SCRIPTS / "env.sh")],
|
||||||
|
env=env, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.splitlines(), [str(self.path), str(SCRIPTS / "get-task")])
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectConfigTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
sys.path.insert(0, str(SCRIPTS / "lib"))
|
||||||
|
try:
|
||||||
|
import task_context
|
||||||
|
self.context = task_context
|
||||||
|
finally:
|
||||||
|
sys.path.pop(0)
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.path = Path(self.temp.name) / "project-config.json"
|
||||||
|
self.config = {
|
||||||
|
"name": "The Lighthouse Novel",
|
||||||
|
"description": "A novel about a coastal community.",
|
||||||
|
"commands": {},
|
||||||
|
}
|
||||||
|
self.path.write_text(json.dumps(self.config))
|
||||||
|
|
||||||
|
def test_nonsoftware_configuration_in_prompt(self):
|
||||||
|
task = {
|
||||||
|
"id": "BOOK-001", "title": "Outline chapter one", "project": "manuscript",
|
||||||
|
"status": "triage", "type": "task", "description": "Review the narrative arc.",
|
||||||
|
}
|
||||||
|
with patch.object(self.context, "load_project_config", return_value=self.context.load_project_config(self.path)):
|
||||||
|
prompt = self.context.build_workflow_prompt(task, "triaging", "Outline the chapter")
|
||||||
|
self.assertIn("for The Lighthouse Novel.", prompt)
|
||||||
|
self.assertIn(self.config["description"], prompt)
|
||||||
|
self.assertIn("No commands configured", prompt)
|
||||||
|
self.assertIn("Do not assume this is software", prompt)
|
||||||
|
self.assertIn("AGENTS.md", prompt)
|
||||||
|
|
||||||
|
def test_arbitrary_named_commands_are_guidance(self):
|
||||||
|
self.config["commands"] = {"word-count": "wc -w manuscript.txt"}
|
||||||
|
self.path.write_text(json.dumps(self.config))
|
||||||
|
config = self.context.load_project_config(self.path)
|
||||||
|
task = {"id": "1", "title": "Review", "project": "manuscript", "status": "triage", "type": "task"}
|
||||||
|
with patch.object(self.context, "load_project_config", return_value=config):
|
||||||
|
prompt = self.context.build_workflow_prompt(task, "reviewing", "Review")
|
||||||
|
self.assertIn("- word-count: `wc -w manuscript.txt`", prompt)
|
||||||
|
self.assertIn("not automatically executed", prompt)
|
||||||
|
|
||||||
|
def test_invalid_configuration_is_reported(self):
|
||||||
|
invalid_configs = (
|
||||||
|
[], {}, {**self.config, "name": ""}, {**self.config, "description": None},
|
||||||
|
{**self.config, "commands": []}, {**self.config, "commands": {"test": None}},
|
||||||
|
{**self.config, "commands": {"": "echo ok"}},
|
||||||
|
{**self.config, "commands": {"review": " "}},
|
||||||
|
)
|
||||||
|
for config in invalid_configs:
|
||||||
|
with self.subTest(config=config):
|
||||||
|
self.path.write_text(json.dumps(config))
|
||||||
|
with self.assertRaisesRegex(ValueError, "Project configuration"):
|
||||||
|
self.context.load_project_config(self.path)
|
||||||
|
self.path.write_text("{")
|
||||||
|
with self.assertRaisesRegex(ValueError, "Cannot read project configuration"):
|
||||||
|
self.context.load_project_config(self.path)
|
||||||
|
with self.assertRaisesRegex(ValueError, "Cannot read project configuration"):
|
||||||
|
self.context.load_project_config(self.path.parent / "missing.json")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
63
scripts/triage
Executable file
63
scripts/triage
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
triage
|
||||||
|
|
||||||
|
Triages a task from docs/tasks.md using an AI CLI agent (Antigravity or Hermes).
|
||||||
|
Validates that the task is in 'planning' or 'triage' status, prepares full
|
||||||
|
architectural and task context, and launches the agent interactively.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/triage 094
|
||||||
|
./scripts/triage EX-120 --hermes
|
||||||
|
./scripts/triage 094 --agy
|
||||||
|
./scripts/triage --help
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure scripts/lib is importable
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||||
|
|
||||||
|
from task_context import (
|
||||||
|
fetch_task,
|
||||||
|
assert_task_status,
|
||||||
|
build_workflow_prompt,
|
||||||
|
add_common_agent_arguments,
|
||||||
|
resolve_selected_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRIAGE_OBJECTIVES = """- Inspect the project materials to understand the current work and scope for this task.
|
||||||
|
- Clarify ambiguities: ask the user any critical design or scoping questions interactively.
|
||||||
|
- Formulate a clear, actionable checklist of deliverables and review criteria (- [ ]).
|
||||||
|
- Document any open decisions, architectural considerations, or trade-offs in triage notes.
|
||||||
|
- Once scoped, update the task using `./scripts/update-task {task_id}`.
|
||||||
|
- If ready for implementation, propose moving status to 'ready', or keep in 'triage'/'planning' if awaiting user decisions."""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Triage a task in docs/tasks.md using an AI CLI agent. Requires status to be 'planning' or 'triage'."
|
||||||
|
)
|
||||||
|
parser.add_argument("task_id", help="Task number or prefixed ID (e.g. 94, PREFIX-094)")
|
||||||
|
add_common_agent_arguments(parser)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
agent = resolve_selected_agent(args)
|
||||||
|
task = fetch_task(args.task_id, tasks_file=args.file)
|
||||||
|
assert_task_status(task, ["planning", "triage"], command_name="triage")
|
||||||
|
|
||||||
|
prompt = build_workflow_prompt(
|
||||||
|
task=task,
|
||||||
|
workflow_name="triaging",
|
||||||
|
workflow_objective=TRIAGE_OBJECTIVES.format(task_id=task["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Launching {agent.name.capitalize()} ({agent.name}) to triage {task['id']}: {task['title']}...")
|
||||||
|
agent.launch(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
283
scripts/update-task
Executable file
283
scripts/update-task
Executable file
@@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
update-task
|
||||||
|
|
||||||
|
Modifies metadata, title, and/or description of a specific task in docs/tasks.md
|
||||||
|
and automatically synchronizes the document tables and sorting.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/update-task EX-018 --status ready
|
||||||
|
./scripts/update-task EX-018 --title "Updated Title"
|
||||||
|
./scripts/update-task EX-018 --description "New description..."
|
||||||
|
./scripts/update-task EX-018 --append-description "- [ ] Additional checklist item"
|
||||||
|
./scripts/update-task EX-018 --check-all
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_update_tasks_module():
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
update_script = script_dir / "update-tasks"
|
||||||
|
if not update_script.exists():
|
||||||
|
raise FileNotFoundError(f"Could not find update-tasks at {update_script}")
|
||||||
|
loader = importlib.machinery.SourceFileLoader("update_tasks", str(update_script))
|
||||||
|
spec = importlib.util.spec_from_loader("update_tasks", loader)
|
||||||
|
if spec is None:
|
||||||
|
raise ImportError(f"Could not load spec for {update_script}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def parse_task_id(id_str):
|
||||||
|
return load_update_tasks_module().parse_task_id(id_str)
|
||||||
|
|
||||||
|
|
||||||
|
def update_task_detail(
|
||||||
|
task_id_input,
|
||||||
|
title=None,
|
||||||
|
project=None,
|
||||||
|
subproject=None,
|
||||||
|
clear_subproject=False,
|
||||||
|
status=None,
|
||||||
|
task_type=None,
|
||||||
|
description=None,
|
||||||
|
append_description=None,
|
||||||
|
check_items=None,
|
||||||
|
uncheck_items=None,
|
||||||
|
check_all=False,
|
||||||
|
uncheck_all=False,
|
||||||
|
sync=True,
|
||||||
|
tasks_file=None,
|
||||||
|
epic=None,
|
||||||
|
clear_epic=False,
|
||||||
|
):
|
||||||
|
update_mod = load_update_tasks_module()
|
||||||
|
tasks_path = update_mod.find_tasks_file(tasks_file)
|
||||||
|
|
||||||
|
with open(tasks_path, "r", encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
fm_data = update_mod.read_frontmatter(text)
|
||||||
|
prefix = update_mod.get_task_id_prefix(fm_data)
|
||||||
|
target_num = update_mod.parse_task_id(task_id_input)
|
||||||
|
|
||||||
|
detail_header_marker = "## Detailed Tasks\n"
|
||||||
|
detail_start = text.find(detail_header_marker)
|
||||||
|
if detail_start == -1:
|
||||||
|
raise ValueError("## Detailed Tasks heading not found")
|
||||||
|
|
||||||
|
notes_marker = "\n---\n\n## Notes\n"
|
||||||
|
notes_start = text.find(notes_marker)
|
||||||
|
if notes_start == -1:
|
||||||
|
notes_start = len(text)
|
||||||
|
|
||||||
|
detail_body = text[detail_start + len(detail_header_marker) : notes_start]
|
||||||
|
task_chunks = re.split(r"(?=<a\s+[^>]*\bid=|<id=)", detail_body)
|
||||||
|
|
||||||
|
found = False
|
||||||
|
new_chunks = []
|
||||||
|
|
||||||
|
for chunk in task_chunks:
|
||||||
|
chunk_clean = chunk.strip()
|
||||||
|
if not chunk_clean:
|
||||||
|
continue
|
||||||
|
m = re.search(update_mod.TASK_ID_PATTERN, chunk_clean, re.IGNORECASE)
|
||||||
|
if m and int(m.group(2)) == target_num and update_mod.task_id_matches(task_id_input, m.group(1), prefix):
|
||||||
|
found = True
|
||||||
|
anchor_match = re.search(update_mod.TASK_ANCHOR_PATTERN, chunk_clean, re.IGNORECASE)
|
||||||
|
if not anchor_match:
|
||||||
|
raise ValueError(f"Anchor tag missing in task {target_num}")
|
||||||
|
anchor_tag = anchor_match.group(1)
|
||||||
|
|
||||||
|
# Current metadata
|
||||||
|
proj_m = re.search(r'data-project=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
subproj_m = re.search(r'data-subproject=["\']([^"\']*)["\']', anchor_tag)
|
||||||
|
status_m = re.search(r'data-status=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
type_m = re.search(r'data-task-type=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
epic_m = re.search(r'data-epic=["\']([^"\']*)["\']', anchor_tag)
|
||||||
|
epic_input = epic if epic is not None else (epic_m.group(1) if epic_m else None)
|
||||||
|
new_epic, epic_label = update_mod.resolve_epic(None if clear_epic else epic_input, fm_data)
|
||||||
|
|
||||||
|
if not subproj_m:
|
||||||
|
subproj_field_m = re.search(r'^\*\*Subproject:\*\*\s*([^\n]+)$', chunk_clean, re.MULTILINE)
|
||||||
|
current_subproj = subproj_field_m.group(1).strip() if subproj_field_m else ""
|
||||||
|
else:
|
||||||
|
current_subproj = subproj_m.group(1).strip()
|
||||||
|
|
||||||
|
current_proj = proj_m.group(1) if proj_m else "shared"
|
||||||
|
current_status = status_m.group(1) if status_m else "triage"
|
||||||
|
current_type = type_m.group(1) if type_m else "feature"
|
||||||
|
|
||||||
|
title_m = re.search(r'^###\s+(.+)$', chunk_clean, re.MULTILINE)
|
||||||
|
current_title = title_m.group(1).strip() if title_m else f"Task {target_num}"
|
||||||
|
|
||||||
|
desc_m = re.search(r'\*\*Description:\*\*\s*\n(.*)$', chunk_clean, re.DOTALL)
|
||||||
|
current_desc = desc_m.group(1).strip() if desc_m else ""
|
||||||
|
|
||||||
|
# Apply modifications
|
||||||
|
new_proj = project.strip().lower() if project else current_proj
|
||||||
|
new_status = status.strip().lower() if status else current_status
|
||||||
|
new_type = task_type.strip().lower() if task_type else current_type
|
||||||
|
new_title = title.strip() if title else current_title
|
||||||
|
|
||||||
|
if clear_subproject or (subproject is not None and subproject.strip().lower() in ("", "none", "null", "clear")):
|
||||||
|
new_subproj = None
|
||||||
|
elif subproject is not None:
|
||||||
|
new_subproj = subproject.strip().lower()
|
||||||
|
else:
|
||||||
|
new_subproj = current_subproj if current_subproj else None
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
new_desc = description.strip()
|
||||||
|
else:
|
||||||
|
new_desc = current_desc
|
||||||
|
|
||||||
|
if append_description:
|
||||||
|
if new_desc:
|
||||||
|
new_desc = new_desc + "\n" + append_description.strip()
|
||||||
|
else:
|
||||||
|
new_desc = append_description.strip()
|
||||||
|
|
||||||
|
# Handle checkboxes
|
||||||
|
if check_all:
|
||||||
|
new_desc = re.sub(r'-\s*\[\s*\]', '- [x]', new_desc)
|
||||||
|
elif uncheck_all:
|
||||||
|
new_desc = re.sub(r'-\s*\[x\]', '- [ ]', new_desc)
|
||||||
|
|
||||||
|
if check_items:
|
||||||
|
desc_lines = new_desc.splitlines()
|
||||||
|
cb_count = 0
|
||||||
|
for i, line in enumerate(desc_lines):
|
||||||
|
if re.match(r'^\s*-\s*\[[\s x]\]', line):
|
||||||
|
cb_count += 1
|
||||||
|
for ci in check_items:
|
||||||
|
if str(ci).isdigit() and int(ci) == cb_count:
|
||||||
|
desc_lines[i] = re.sub(r'-\s*\[[\s x]\]', '- [x]', line, count=1)
|
||||||
|
elif isinstance(ci, str) and ci.lower() in line.lower():
|
||||||
|
desc_lines[i] = re.sub(r'-\s*\[[\s x]\]', '- [x]', line, count=1)
|
||||||
|
new_desc = "\n".join(desc_lines)
|
||||||
|
|
||||||
|
if uncheck_items:
|
||||||
|
desc_lines = new_desc.splitlines()
|
||||||
|
cb_count = 0
|
||||||
|
for i, line in enumerate(desc_lines):
|
||||||
|
if re.match(r'^\s*-\s*\[[\s x]\]', line):
|
||||||
|
cb_count += 1
|
||||||
|
for ui in uncheck_items:
|
||||||
|
if str(ui).isdigit() and int(ui) == cb_count:
|
||||||
|
desc_lines[i] = re.sub(r'-\s*\[[\s x]\]', '- [ ]', line, count=1)
|
||||||
|
elif isinstance(ui, str) and ui.lower() in line.lower():
|
||||||
|
desc_lines[i] = re.sub(r'-\s*\[[\s x]\]', '- [ ]', line, count=1)
|
||||||
|
new_desc = "\n".join(desc_lines)
|
||||||
|
|
||||||
|
digit_width = max(3, len(m.group(1).rsplit("-", 1)[1]))
|
||||||
|
normalized_id = update_mod.format_task_id(target_num, prefix, digit_width)
|
||||||
|
normalized_aid = m.group(1)
|
||||||
|
|
||||||
|
# Assemble modified chunk (update-tasks will format headers/labels upon sync)
|
||||||
|
if new_subproj:
|
||||||
|
anchor_tag_str = f'<a id="{normalized_aid}" class="task" data-project="{new_proj}" data-subproject="{new_subproj}" data-status="{new_status}" data-task-type="{new_type}"></a>'
|
||||||
|
subproj_line = f"**Subproject:** {new_subproj.capitalize()}\n"
|
||||||
|
else:
|
||||||
|
anchor_tag_str = f'<a id="{normalized_aid}" class="task" data-project="{new_proj}" data-status="{new_status}" data-task-type="{new_type}"></a>'
|
||||||
|
subproj_line = ""
|
||||||
|
|
||||||
|
if new_epic:
|
||||||
|
anchor_tag_str = anchor_tag_str.replace('></a>', f' data-epic="{new_epic}"></a>')
|
||||||
|
epic_line = f"**Epic:** {epic_label}\n" if new_epic else ""
|
||||||
|
|
||||||
|
updated_chunk = (
|
||||||
|
f'{anchor_tag_str}\n'
|
||||||
|
f"### {new_title}\n"
|
||||||
|
f"**ID:** {normalized_id}\n"
|
||||||
|
f"**Project:** {new_proj.capitalize()}\n"
|
||||||
|
f"{subproj_line}"
|
||||||
|
f"{epic_line}"
|
||||||
|
f"**Status:** {new_status.capitalize()}\n"
|
||||||
|
f"**Type:** {new_type.capitalize()}\n\n"
|
||||||
|
f"**Description:**\n"
|
||||||
|
f"{new_desc}"
|
||||||
|
)
|
||||||
|
new_chunks.append(updated_chunk)
|
||||||
|
else:
|
||||||
|
new_chunks.append(chunk_clean)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
raise KeyError(f"Task {task_id_input} ({update_mod.format_task_id(target_num, prefix)}) was not found in {tasks_path}")
|
||||||
|
|
||||||
|
# Reconstruct document
|
||||||
|
new_detail_body = "\n\n".join(new_chunks) + "\n"
|
||||||
|
updated_text = text[: detail_start + len(detail_header_marker)] + new_detail_body + text[notes_start:]
|
||||||
|
|
||||||
|
with open(tasks_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(updated_text)
|
||||||
|
|
||||||
|
if sync:
|
||||||
|
update_mod.update_tasks_document(tasks_path)
|
||||||
|
|
||||||
|
return update_mod.format_task_id(target_num, prefix, digit_width), tasks_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Modify task details and synchronize tasks.md")
|
||||||
|
parser.add_argument("task_id", help="Task number or prefixed ID (e.g. 18, PREFIX-018)")
|
||||||
|
parser.add_argument("--epic", default=None, help="Update epic using a frontmatter value or label")
|
||||||
|
parser.add_argument("--clear-epic", action="store_true", help="Remove epic from task")
|
||||||
|
parser.add_argument("-T", "--title", default=None, help="Update task title")
|
||||||
|
parser.add_argument("-p", "--project", default=None, help="Update project (e.g. android, editor, shared, story-engine)")
|
||||||
|
parser.add_argument("-S", "--subproject", default=None, help="Update subproject (e.g. actions, expressions, schema)")
|
||||||
|
parser.add_argument("--clear-subproject", action="store_true", help="Remove subproject from task")
|
||||||
|
parser.add_argument("-s", "--status", default=None, help="Update status (e.g. triage, pending, in_progress, done, blocked, cancelled)")
|
||||||
|
parser.add_argument("-t", "--type", default=None, help="Update task type (e.g. feature, bug, lint, chore)")
|
||||||
|
parser.add_argument("-d", "--description", default=None, help="Replace entire description body")
|
||||||
|
parser.add_argument("--desc-file", default=None, help="File containing new description body")
|
||||||
|
parser.add_argument("-a", "--append-description", default=None, help="Append text to existing description")
|
||||||
|
parser.add_argument("--check", action="append", default=[], help="Checkbox index (1-based) or substring to check [x]")
|
||||||
|
parser.add_argument("--uncheck", action="append", default=[], help="Checkbox index (1-based) or substring to uncheck [ ]")
|
||||||
|
parser.add_argument("--check-all", action="store_true", help="Mark all checkboxes in this task as checked [x]")
|
||||||
|
parser.add_argument("--uncheck-all", action="store_true", help="Mark all checkboxes in this task as unchecked [ ]")
|
||||||
|
parser.add_argument("--no-sync", action="store_true", help="Do not run update-tasks table sync")
|
||||||
|
parser.add_argument("-f", "--file", default=None, help="Path to tasks.md (optional)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
desc_content = args.description
|
||||||
|
if args.desc_file:
|
||||||
|
with open(args.desc_file, "r", encoding="utf-8") as df:
|
||||||
|
desc_content = df.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
formatted_id, path = update_task_detail(
|
||||||
|
task_id_input=args.task_id,
|
||||||
|
title=args.title,
|
||||||
|
project=args.project,
|
||||||
|
subproject=args.subproject,
|
||||||
|
clear_subproject=args.clear_subproject,
|
||||||
|
status=args.status,
|
||||||
|
task_type=args.type,
|
||||||
|
description=desc_content,
|
||||||
|
append_description=args.append_description,
|
||||||
|
check_items=args.check,
|
||||||
|
uncheck_items=args.uncheck,
|
||||||
|
check_all=args.check_all,
|
||||||
|
uncheck_all=args.uncheck_all,
|
||||||
|
sync=not args.no_sync,
|
||||||
|
tasks_file=args.file,
|
||||||
|
epic=args.epic,
|
||||||
|
clear_epic=args.clear_epic,
|
||||||
|
)
|
||||||
|
print(f"Successfully updated task {formatted_id} in {path}")
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"Error: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
574
scripts/update-tasks
Executable file
574
scripts/update-tasks
Executable file
@@ -0,0 +1,574 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
update-tasks
|
||||||
|
|
||||||
|
Synchronizes and regenerates docs/tasks.md:
|
||||||
|
1. Parses YAML frontmatter for projects, epics, task-statuses, task-types, and task-id-prefix.
|
||||||
|
2. Extracts all task chunks and sorts the Detailed Tasks section strictly by numeric task ID.
|
||||||
|
3. Updates the Detailed Tasks section:
|
||||||
|
- Normalizes all task IDs to uniform digit width (minimum 3 digits, e.g. EX-001).
|
||||||
|
- Synchronizes task detail blocks (including optional **Epic:**) with anchor tags (SSOT).
|
||||||
|
4. Re-renders the Overall Progress Table (tasks-list) from the sorted, updated task details:
|
||||||
|
- Bold (**Title**) for pending tasks
|
||||||
|
- Italic (*Title*) for planning and triage tasks
|
||||||
|
- Plain (Title) for fixed/done/other tasks
|
||||||
|
- Type column links ([Type](#ex-xxx))
|
||||||
|
5. Re-renders the metadata derived enum tables at the end of the document:
|
||||||
|
- Projects (Rendered from frontmatter projects)
|
||||||
|
- Task Statuses (Rendered from frontmatter task-statuses)
|
||||||
|
- Task Types (Rendered from frontmatter task-types)
|
||||||
|
6. Assembles and writes the updated document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def find_tasks_file(explicit_path=None):
|
||||||
|
explicit_path = explicit_path or os.environ.get("TASKS_FILE")
|
||||||
|
if explicit_path:
|
||||||
|
p = Path(explicit_path)
|
||||||
|
if p.is_file():
|
||||||
|
return p
|
||||||
|
raise FileNotFoundError(f"Specified file not found: {explicit_path}")
|
||||||
|
|
||||||
|
# Search upwards or relative to script / cwd
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
candidates = [
|
||||||
|
Path.cwd() / "docs" / "tasks.md",
|
||||||
|
Path.cwd() / "docs" / "Tasks.md",
|
||||||
|
Path.cwd() / "Documentation" / "tasks.md",
|
||||||
|
Path.cwd() / "Documentation" / "Tasks.md",
|
||||||
|
Path.cwd() / "tasks.md",
|
||||||
|
Path.cwd() / "Tasks.md",
|
||||||
|
script_dir.parent / "docs" / "tasks.md",
|
||||||
|
script_dir.parent / "docs" / "Tasks.md",
|
||||||
|
script_dir.parent / "Documentation" / "tasks.md",
|
||||||
|
script_dir.parent / "Documentation" / "Tasks.md",
|
||||||
|
script_dir / "docs" / "tasks.md",
|
||||||
|
script_dir / "docs" / "Tasks.md",
|
||||||
|
script_dir / "Documentation" / "tasks.md",
|
||||||
|
script_dir / "Documentation" / "Tasks.md",
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
|
||||||
|
raise FileNotFoundError("Could not locate docs/tasks.md or docs/Tasks.md")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yaml_frontmatter(fm_text):
|
||||||
|
sections = {}
|
||||||
|
current_section = None
|
||||||
|
current_item = None
|
||||||
|
|
||||||
|
for line in fm_text.splitlines():
|
||||||
|
if line and not line.startswith(" ") and ":" in line and not line.endswith(":"):
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
sections[key.strip()] = value.strip().strip("\"'")
|
||||||
|
current_section = None
|
||||||
|
current_item = None
|
||||||
|
elif line and not line.startswith(" ") and line.endswith(":"):
|
||||||
|
current_section = line[:-1].strip()
|
||||||
|
sections[current_section] = []
|
||||||
|
current_item = None
|
||||||
|
elif current_section is not None:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if line.startswith(" - "):
|
||||||
|
current_item = {}
|
||||||
|
sections[current_section].append(current_item)
|
||||||
|
kv = line[4:].strip()
|
||||||
|
if ":" in kv:
|
||||||
|
k, v = kv.split(":", 1)
|
||||||
|
current_item[k.strip()] = v.strip().strip("\"'")
|
||||||
|
elif line.startswith(" ") and current_item is not None:
|
||||||
|
kv = line.strip()
|
||||||
|
if ":" in kv:
|
||||||
|
k, v = kv.split(":", 1)
|
||||||
|
current_item[k.strip()] = v.strip().strip("\"'")
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
TASK_ID_PATTERN = r'\bid=["\']([a-z][a-z0-9_-]*-(\d+))["\']'
|
||||||
|
TASK_ANCHOR_PATTERN = r'<(?:a\s+)?([^>]*' + TASK_ID_PATTERN + r'[^>]*)>'
|
||||||
|
|
||||||
|
|
||||||
|
def read_frontmatter(text):
|
||||||
|
match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("YAML frontmatter not found in tasks.md")
|
||||||
|
return parse_yaml_frontmatter(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_id_prefix(fm_data):
|
||||||
|
prefix = os.environ.get("TASK_ID_PREFIX", fm_data.get("task-id-prefix", "EX"))
|
||||||
|
if not isinstance(prefix, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", prefix):
|
||||||
|
raise ValueError("Task ID prefix must start with a letter and contain only letters, digits, underscores, or hyphens")
|
||||||
|
return prefix.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def format_task_id(number, prefix, digit_width=3):
|
||||||
|
return f"{prefix}-{number:0{max(3, digit_width, len(str(number)))}d}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_task_id(id_str):
|
||||||
|
match = re.fullmatch(r"(?:[a-z][a-z0-9_-]*-)?(\d+)", str(id_str).strip(), re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Invalid task ID '{id_str}'; expected a task number or PREFIX-number")
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def task_id_matches(id_str, anchor_id, prefix):
|
||||||
|
number = parse_task_id(id_str)
|
||||||
|
task_id = str(id_str).strip()
|
||||||
|
anchor_prefix, anchor_number = anchor_id.rsplit("-", 1)
|
||||||
|
return number == int(anchor_number) and (
|
||||||
|
task_id.isdigit() or task_id.rsplit("-", 1)[0].lower() in (anchor_prefix.lower(), prefix.lower())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_epic(epic, fm_data):
|
||||||
|
if epic is None or epic.strip().lower() in ("", "none", "null", "clear"):
|
||||||
|
return "", ""
|
||||||
|
for item in fm_data.get("epics", []):
|
||||||
|
value = item.get("value", "").strip()
|
||||||
|
label = item.get("label", "").strip() or value.replace("-", " ").replace("_", " ").title()
|
||||||
|
if value and epic.strip().lower() in (value.lower(), label.lower()):
|
||||||
|
return value, label
|
||||||
|
raise ValueError(f"Unknown epic '{epic}'; use a value or label from frontmatter epics")
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown_table(headers, rows, alignments=None):
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
num_cols = len(headers)
|
||||||
|
widths = [len(h) for h in headers]
|
||||||
|
for row in rows:
|
||||||
|
for i, val in enumerate(row):
|
||||||
|
widths[i] = max(widths[i], len(str(val)))
|
||||||
|
|
||||||
|
if alignments is None:
|
||||||
|
alignments = [":---"] * num_cols
|
||||||
|
|
||||||
|
header_line = "| " + " | ".join(f"{h:<{widths[i]}}" for i, h in enumerate(headers)) + " |\n"
|
||||||
|
separator_parts = []
|
||||||
|
for i, a in enumerate(alignments):
|
||||||
|
w = max(widths[i], 3)
|
||||||
|
if a.startswith(":") and a.endswith(":"):
|
||||||
|
separator_parts.append(":" + "-" * (w - 2) + ":")
|
||||||
|
elif a.endswith(":"):
|
||||||
|
separator_parts.append("-" * (w - 1) + ":")
|
||||||
|
else:
|
||||||
|
separator_parts.append(":" + "-" * (w - 1))
|
||||||
|
separator_line = "|" + "|".join(separator_parts) + "|\n"
|
||||||
|
|
||||||
|
row_lines = []
|
||||||
|
for row in rows:
|
||||||
|
line = "| " + " | ".join(f"{str(row[i]):<{widths[i]}}" for i in range(num_cols)) + " |\n"
|
||||||
|
row_lines.append(line)
|
||||||
|
|
||||||
|
return header_line + separator_line + "".join(row_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def update_tasks_document(tasks_path):
|
||||||
|
tasks_path = Path(tasks_path)
|
||||||
|
with open(tasks_path, "r", encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# 1. Parse frontmatter
|
||||||
|
fm_match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
|
||||||
|
if not fm_match:
|
||||||
|
raise ValueError("YAML frontmatter not found in tasks.md")
|
||||||
|
|
||||||
|
fm_text = fm_match.group(1)
|
||||||
|
fm_data = parse_yaml_frontmatter(fm_text)
|
||||||
|
prefix = get_task_id_prefix(fm_data)
|
||||||
|
|
||||||
|
project_list = fm_data.get("projects", [])
|
||||||
|
epic_list = fm_data.get("epics", [])
|
||||||
|
subproject_list = fm_data.get("subprojects", [])
|
||||||
|
status_list = fm_data.get("task-statuses", [])
|
||||||
|
type_list = fm_data.get("task-types", [])
|
||||||
|
|
||||||
|
# Maps for lookup (both value and label matching)
|
||||||
|
project_map = {}
|
||||||
|
project_value_map = {}
|
||||||
|
for p in project_list:
|
||||||
|
val = p.get("value", "").strip()
|
||||||
|
lbl = p.get("label", "").strip()
|
||||||
|
if val:
|
||||||
|
project_map[val.lower()] = lbl or val.capitalize()
|
||||||
|
project_value_map[val.lower()] = val
|
||||||
|
if lbl:
|
||||||
|
project_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
project_value_map[lbl.lower()] = val
|
||||||
|
|
||||||
|
subproject_map = {}
|
||||||
|
subproject_value_map = {}
|
||||||
|
for sp in subproject_list:
|
||||||
|
val = sp.get("value", "").strip()
|
||||||
|
lbl = sp.get("label", "").strip()
|
||||||
|
if val:
|
||||||
|
subproject_map[val.lower()] = lbl or val.replace("-", " ").title()
|
||||||
|
subproject_value_map[val.lower()] = val
|
||||||
|
if lbl:
|
||||||
|
subproject_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
subproject_value_map[lbl.lower()] = val
|
||||||
|
|
||||||
|
status_map = {s.get("value", "").lower(): s.get("label", "") for s in status_list}
|
||||||
|
status_value_map = {s.get("value", "").lower(): s.get("value", "") for s in status_list}
|
||||||
|
for s in status_list:
|
||||||
|
lbl = s.get("label", "").strip()
|
||||||
|
val = s.get("value", "").strip()
|
||||||
|
if lbl:
|
||||||
|
status_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
status_value_map[lbl.lower()] = val
|
||||||
|
# Accept 'done' or 'fixed' fallbacks if not defined in frontmatter
|
||||||
|
status_map.setdefault("fixed", status_map.get("done", "Done"))
|
||||||
|
status_map.setdefault("done", status_map.get("fixed", "Done"))
|
||||||
|
status_value_map.setdefault("fixed", "done")
|
||||||
|
status_value_map.setdefault("done", "done")
|
||||||
|
|
||||||
|
type_map = {t.get("value", "").lower(): t.get("label", "") for t in type_list}
|
||||||
|
type_value_map = {t.get("value", "").lower(): t.get("value", "") for t in type_list}
|
||||||
|
for t in type_list:
|
||||||
|
lbl = t.get("label", "").strip()
|
||||||
|
val = t.get("value", "").strip()
|
||||||
|
if lbl:
|
||||||
|
type_map[lbl.lower()] = lbl
|
||||||
|
if val:
|
||||||
|
type_value_map[lbl.lower()] = val
|
||||||
|
|
||||||
|
# 2. Locate boundaries
|
||||||
|
m_detail = re.search(r"^## Detailed Tasks\s*$", text, re.MULTILINE)
|
||||||
|
if not m_detail:
|
||||||
|
raise ValueError("## Detailed Tasks heading not found")
|
||||||
|
detail_start = m_detail.start()
|
||||||
|
detail_header_len = len(m_detail.group(0))
|
||||||
|
|
||||||
|
m_notes = re.search(r"(?:\n\s*---\s*)?\n\s*## Notes\s*$", text, re.MULTILINE)
|
||||||
|
if not m_notes:
|
||||||
|
raise ValueError("## Notes section marker not found")
|
||||||
|
notes_start = m_notes.start()
|
||||||
|
|
||||||
|
# Determine part_a (everything before the summary table)
|
||||||
|
tasks_list_m = re.search(r'<a\s+[^>]*\bid=["\']tasks-list["\'][^>]*></a>', text, re.IGNORECASE)
|
||||||
|
progress_m = re.search(r'^## Overall Progress\s*$', text, re.MULTILINE)
|
||||||
|
|
||||||
|
if tasks_list_m and tasks_list_m.start() < detail_start:
|
||||||
|
part_a = text[:tasks_list_m.end()].rstrip() + "\n\n"
|
||||||
|
table_search_start = tasks_list_m.end()
|
||||||
|
elif progress_m and progress_m.start() < detail_start:
|
||||||
|
part_a = text[:progress_m.end()].rstrip() + "\n\n"
|
||||||
|
table_search_start = progress_m.end()
|
||||||
|
else:
|
||||||
|
table_start_marker = "| ID"
|
||||||
|
table_start = text.find(table_start_marker)
|
||||||
|
if table_start == -1 or table_start >= detail_start:
|
||||||
|
raise ValueError("Overall Progress Table (| ID) not found")
|
||||||
|
# Strip any leading git conflict markers right before | ID from part_a
|
||||||
|
raw_part_a = text[:table_start]
|
||||||
|
raw_part_a = re.sub(r'<{7}\s+[^\n]*\n*$', '', raw_part_a)
|
||||||
|
part_a = raw_part_a.rstrip() + "\n\n"
|
||||||
|
table_search_start = table_start
|
||||||
|
|
||||||
|
# Determine part_c (the transition into ## Detailed Tasks)
|
||||||
|
# Check if <a id="task-details"></a> was present between table and ## Detailed Tasks
|
||||||
|
between_table_and_detail = text[table_search_start:detail_start]
|
||||||
|
has_task_details_anchor = bool(re.search(r'<a\s+[^>]*\bid=["\']task-details["\'][^>]*>', between_table_and_detail, re.IGNORECASE))
|
||||||
|
|
||||||
|
if has_task_details_anchor:
|
||||||
|
part_c = "\n\n---\n\n<a id=\"task-details\"></a>\n\n## Detailed Tasks\n"
|
||||||
|
else:
|
||||||
|
part_c = "\n\n---\n\n## Detailed Tasks\n"
|
||||||
|
|
||||||
|
detail_body = text[detail_start + detail_header_len : notes_start]
|
||||||
|
|
||||||
|
# 3. Extract and Sort Task Chunks by Numeric ID
|
||||||
|
task_chunks = re.split(r"(?=<a\s+[^>]*\bid=|<id=)", detail_body)
|
||||||
|
task_chunks = [c.strip() for c in task_chunks if c.strip()]
|
||||||
|
|
||||||
|
if not task_chunks:
|
||||||
|
raise ValueError("No task chunks found in Detailed Tasks")
|
||||||
|
|
||||||
|
extracted_chunks = []
|
||||||
|
for chunk in task_chunks:
|
||||||
|
m = re.search(TASK_ID_PATTERN, chunk, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"Could not find task ID in chunk:\n{chunk[:150]}")
|
||||||
|
num = int(m.group(2))
|
||||||
|
extracted_chunks.append((num, chunk))
|
||||||
|
|
||||||
|
seen_nums = {}
|
||||||
|
for num, chunk in extracted_chunks:
|
||||||
|
seen_nums.setdefault(num, []).append(chunk)
|
||||||
|
|
||||||
|
duplicates = [num for num, chunks in seen_nums.items() if len(chunks) > 1]
|
||||||
|
if duplicates:
|
||||||
|
prefix = get_task_id_prefix(fm_data)
|
||||||
|
dup_ids = [format_task_id(num, prefix) for num in sorted(duplicates)]
|
||||||
|
raise ValueError(f"Duplicate numeric task IDs found in Detailed Tasks: {', '.join(dup_ids)}")
|
||||||
|
|
||||||
|
# Sort task detail section chunks by numeric ID
|
||||||
|
extracted_chunks.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
max_id_num = extracted_chunks[-1][0] if extracted_chunks else 1
|
||||||
|
digit_width = max(3, len(str(max_id_num)))
|
||||||
|
|
||||||
|
# 4. Update the Task Detail Section (First)
|
||||||
|
updated_detailed_chunks = []
|
||||||
|
updated_task_records = []
|
||||||
|
|
||||||
|
header_block_pattern = r'<(?:a\s+[^>]*\bid=|\bid=)["\'][a-zA-Z][a-zA-Z0-9_-]*-\d+["\'][^>]*></a>\s*\n###\s+[^\n]+\n\*\*ID:\*\*[^\n]+\n\*\*Project:\*\*[^\n]+(?:\n\*\*(?:Subproject|Epic):\*\*[^\n]*)*\n\*\*Status:\*\*[^\n]+\n\*\*Type:\*\*[^\n]+'
|
||||||
|
anchor_replacements = {}
|
||||||
|
|
||||||
|
for num, chunk in extracted_chunks:
|
||||||
|
anchor_match = re.search(TASK_ANCHOR_PATTERN, chunk, re.IGNORECASE)
|
||||||
|
if not anchor_match:
|
||||||
|
raise ValueError(f"Anchor tag missing in task chunk {num}")
|
||||||
|
anchor_tag = anchor_match.group(1)
|
||||||
|
|
||||||
|
proj_m = re.search(r'data-project=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
subproj_m = re.search(r'data-subproject=["\']([^"\']*)["\']', anchor_tag)
|
||||||
|
status_m = re.search(r'data-status=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
type_m = re.search(r'data-task-type=["\']([^"\']+)["\']', anchor_tag)
|
||||||
|
epic_m = re.search(r'data-epic=["\']([^"\']*)["\']', anchor_tag)
|
||||||
|
epic_key, expected_epic = resolve_epic(epic_m.group(1) if epic_m else None, fm_data)
|
||||||
|
|
||||||
|
# Also check for **Subproject:** in chunk if anchor tag didn't have data-subproject
|
||||||
|
if not subproj_m:
|
||||||
|
subproj_field_m = re.search(r'^\*\*Subproject:\*\*\s*([^\n]+)$', chunk, re.MULTILINE)
|
||||||
|
subproj_val = subproj_field_m.group(1).strip() if subproj_field_m else None
|
||||||
|
else:
|
||||||
|
subproj_val = subproj_m.group(1).strip() if subproj_m.group(1).strip() else None
|
||||||
|
|
||||||
|
proj_raw = proj_m.group(1).strip() if proj_m else "shared"
|
||||||
|
status_raw = status_m.group(1).strip() if status_m else "triage"
|
||||||
|
type_raw = type_m.group(1).strip() if type_m else "feature"
|
||||||
|
|
||||||
|
proj_key = project_value_map.get(proj_raw.lower(), proj_raw.lower())
|
||||||
|
status_key = status_value_map.get(status_raw.lower(), status_raw.lower())
|
||||||
|
type_key = type_value_map.get(type_raw.lower(), type_raw.lower())
|
||||||
|
|
||||||
|
expected_proj = project_map.get(proj_key, project_map.get(proj_raw.lower(), proj_raw.capitalize()))
|
||||||
|
expected_status = status_map.get(status_key, status_map.get(status_raw.lower(), status_raw.capitalize()))
|
||||||
|
expected_type = type_map.get(type_key, type_map.get(type_raw.lower(), type_raw.capitalize()))
|
||||||
|
|
||||||
|
subproj_key = None
|
||||||
|
expected_subproj = None
|
||||||
|
if subproj_val and subproj_val.lower() not in ("", "none", "null"):
|
||||||
|
subproj_key = subproject_value_map.get(subproj_val.lower(), subproj_val.lower())
|
||||||
|
expected_subproj = subproject_map.get(subproj_key, subproject_map.get(subproj_val.lower(), subproj_val.replace("-", " ").replace("_", " ").title()))
|
||||||
|
|
||||||
|
title_m = re.search(r'^###\s+(.+)$', chunk, re.MULTILINE)
|
||||||
|
title = title_m.group(1).strip() if title_m else f"Task {num}"
|
||||||
|
|
||||||
|
normalized_id = format_task_id(num, prefix, digit_width)
|
||||||
|
normalized_aid = normalized_id.lower()
|
||||||
|
anchor_replacements[anchor_match.group(2).lower()] = normalized_aid
|
||||||
|
|
||||||
|
if subproj_key:
|
||||||
|
anchor_tag_str = f'<a id="{normalized_aid}" class="task" data-project="{proj_key}" data-subproject="{subproj_key}" data-status="{status_key}" data-task-type="{type_key}"></a>'
|
||||||
|
subproj_line = f"**Subproject:** {expected_subproj}\n"
|
||||||
|
else:
|
||||||
|
anchor_tag_str = f'<a id="{normalized_aid}" class="task" data-project="{proj_key}" data-status="{status_key}" data-task-type="{type_key}"></a>'
|
||||||
|
subproj_line = ""
|
||||||
|
|
||||||
|
if epic_key:
|
||||||
|
anchor_tag_str = anchor_tag_str.replace('></a>', f' data-epic="{epic_key}"></a>')
|
||||||
|
epic_line = f"**Epic:** {expected_epic}\n" if epic_key else ""
|
||||||
|
|
||||||
|
new_header_block = (
|
||||||
|
f'{anchor_tag_str}\n'
|
||||||
|
f"### {title}\n"
|
||||||
|
f"**ID:** {normalized_id}\n"
|
||||||
|
f"**Project:** {expected_proj}\n"
|
||||||
|
f"{subproj_line}"
|
||||||
|
f"{epic_line}"
|
||||||
|
f"**Status:** {expected_status}\n"
|
||||||
|
f"**Type:** {expected_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if re.search(header_block_pattern, chunk):
|
||||||
|
updated_chunk = re.sub(header_block_pattern, new_header_block, chunk, count=1)
|
||||||
|
else:
|
||||||
|
# Fallback if header spacing or fields differed slightly
|
||||||
|
rest = chunk.split("**Type:**", 1)[1].lstrip().split("\n", 1)[1] if "**Type:**" in chunk else ""
|
||||||
|
updated_chunk = f"{new_header_block}\n\n{rest}".rstrip()
|
||||||
|
|
||||||
|
updated_detailed_chunks.append(updated_chunk)
|
||||||
|
|
||||||
|
updated_task_records.append({
|
||||||
|
"num": num,
|
||||||
|
"id": normalized_id,
|
||||||
|
"aid": normalized_aid,
|
||||||
|
"title": title,
|
||||||
|
"project": expected_proj,
|
||||||
|
"subproject": expected_subproj or "",
|
||||||
|
"epic": expected_epic,
|
||||||
|
"status": expected_status,
|
||||||
|
"type": expected_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Assemble sorted, updated Detailed Tasks section
|
||||||
|
sorted_detailed_tasks = "\n\n".join(updated_detailed_chunks)
|
||||||
|
|
||||||
|
# 5. Rebuild Summary Table (Rendered from the sorted, updated task details)
|
||||||
|
has_subprojects = any(bool(t.get("subproject")) for t in updated_task_records)
|
||||||
|
if has_subprojects:
|
||||||
|
summary_headers = ["ID", "Title", "Project", "Subproject", "Status", "Type"]
|
||||||
|
alignments = [":---", ":---", ":---", ":---", ":---", ":---"]
|
||||||
|
else:
|
||||||
|
summary_headers = ["ID", "Title", "Project", "Status", "Type"]
|
||||||
|
alignments = [":---", ":---", ":---", ":---", ":---"]
|
||||||
|
has_epics = bool(epic_list)
|
||||||
|
if has_epics:
|
||||||
|
summary_headers.insert(-2, "Epic")
|
||||||
|
alignments.insert(-2, ":---")
|
||||||
|
|
||||||
|
summary_rows = []
|
||||||
|
|
||||||
|
for task in updated_task_records:
|
||||||
|
clean_title = task["title"]
|
||||||
|
while (clean_title.startswith("**") and clean_title.endswith("**")) or (
|
||||||
|
clean_title.startswith("*") and clean_title.endswith("*")
|
||||||
|
):
|
||||||
|
if clean_title.startswith("**") and clean_title.endswith("**"):
|
||||||
|
clean_title = clean_title[2:-2].strip()
|
||||||
|
elif clean_title.startswith("*") and clean_title.endswith("*"):
|
||||||
|
clean_title = clean_title[1:-1].strip()
|
||||||
|
|
||||||
|
st_lower = task["status"].lower()
|
||||||
|
if st_lower == "pending":
|
||||||
|
formatted_title = f"**{clean_title}**"
|
||||||
|
elif st_lower in ("triage", "planning"):
|
||||||
|
formatted_title = f"*{clean_title}*"
|
||||||
|
else:
|
||||||
|
formatted_title = clean_title
|
||||||
|
|
||||||
|
formatted_type = f"[{task['type']}](#{task['aid']})"
|
||||||
|
row = [task["id"], formatted_title, task["project"]]
|
||||||
|
if has_subprojects:
|
||||||
|
row.append(task["subproject"])
|
||||||
|
if has_epics:
|
||||||
|
row.append(task["epic"])
|
||||||
|
row.extend([task["status"], formatted_type])
|
||||||
|
summary_rows.append(row)
|
||||||
|
|
||||||
|
new_summary_table = build_markdown_table(
|
||||||
|
summary_headers, summary_rows, alignments=alignments
|
||||||
|
).rstrip("\n")
|
||||||
|
|
||||||
|
# 8. Re-render metadata derived enum tables at end of document
|
||||||
|
end_text = text[notes_start:]
|
||||||
|
|
||||||
|
# Projects table: Value | Label | Prefix | Path
|
||||||
|
project_headers = ["Value", "Label", "Prefix", "Path"]
|
||||||
|
project_rows = [
|
||||||
|
[p.get("value", ""), p.get("label", ""), p.get("prefix", ""), p.get("path", "")]
|
||||||
|
for p in project_list
|
||||||
|
]
|
||||||
|
projects_table = build_markdown_table(project_headers, project_rows, [":---", ":---", ":---", ":---"]).strip()
|
||||||
|
|
||||||
|
# Statuses table: Value | Label | Description
|
||||||
|
status_headers = ["Value", "Label", "Description"]
|
||||||
|
status_rows = [
|
||||||
|
[s.get("value", ""), s.get("label", ""), s.get("description", "")]
|
||||||
|
for s in status_list
|
||||||
|
]
|
||||||
|
statuses_table = build_markdown_table(status_headers, status_rows, [":---", ":---", ":---"]).strip()
|
||||||
|
|
||||||
|
# Types table: Value | Label | Prefix | Description
|
||||||
|
type_headers = ["Value", "Label", "Prefix", "Description"]
|
||||||
|
type_rows = [
|
||||||
|
[t.get("value", ""), t.get("label", ""), t.get("prefix", ""), t.get("description", "")]
|
||||||
|
for t in type_list
|
||||||
|
]
|
||||||
|
types_table = build_markdown_table(type_headers, type_rows, [":---", ":---", ":---", ":---"]).strip()
|
||||||
|
|
||||||
|
# Subprojects table if present in frontmatter
|
||||||
|
subproject_section = ""
|
||||||
|
if subproject_list:
|
||||||
|
subproject_headers = ["Value", "Label", "Description"]
|
||||||
|
subproject_rows = [
|
||||||
|
[sp.get("value", ""), sp.get("label", ""), sp.get("description", "")]
|
||||||
|
for sp in subproject_list
|
||||||
|
]
|
||||||
|
subprojects_table = build_markdown_table(subproject_headers, subproject_rows, [":---", ":---", ":---"]).strip()
|
||||||
|
subproject_section = f"\n\n#### Subprojects (Rendered from frontmatter subprojects)\n{subprojects_table}"
|
||||||
|
|
||||||
|
epic_section = ""
|
||||||
|
if epic_list:
|
||||||
|
epic_rows = [[e.get("value", ""), e.get("label", ""), e.get("description", "")] for e in epic_list]
|
||||||
|
epics_table = build_markdown_table(["Value", "Label", "Description"], epic_rows).strip()
|
||||||
|
epic_section = f"\n\n#### Epics (Rendered from frontmatter epics)\n{epics_table}"
|
||||||
|
|
||||||
|
# Replace enum tables in end_text
|
||||||
|
enum_tables_pattern = r"(#### Projects \(Rendered from frontmatter projects\)\n).*?(<!-- Table Sort Injection -->)"
|
||||||
|
new_enum_tables = (
|
||||||
|
"#### Projects (Rendered from frontmatter projects)\n"
|
||||||
|
f"{projects_table}\n\n"
|
||||||
|
"#### Task Statuses (Rendered from frontmatter task-statuses)\n"
|
||||||
|
f"{statuses_table}\n\n"
|
||||||
|
"#### Task Types (Rendered from frontmatter task-types)\n"
|
||||||
|
f"{types_table}"
|
||||||
|
f"{subproject_section}{epic_section}\n\n"
|
||||||
|
r"\2"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_end_text = re.sub(enum_tables_pattern, new_enum_tables, end_text, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# 9. Assemble full document
|
||||||
|
new_full_text = (
|
||||||
|
part_a
|
||||||
|
+ new_summary_table
|
||||||
|
+ part_c
|
||||||
|
+ "\n"
|
||||||
|
+ sorted_detailed_tasks
|
||||||
|
+ "\n\n"
|
||||||
|
+ updated_end_text.lstrip("\n")
|
||||||
|
)
|
||||||
|
new_full_text = re.sub(
|
||||||
|
r'(\]\(|href=["\']|^\[[^\]\n]+\]:[ \t]*)#([a-z][a-z0-9_-]*-\d+)(?=[\s)"\']|$)',
|
||||||
|
lambda m: m.group(1) + "#" + anchor_replacements.get(m.group(2).lower(), m.group(2)),
|
||||||
|
new_full_text,
|
||||||
|
flags=re.IGNORECASE | re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(tasks_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_full_text)
|
||||||
|
|
||||||
|
return len(updated_task_records)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="Synchronize and update Documentation/tasks.md")
|
||||||
|
parser.add_argument(
|
||||||
|
"-f", "--file",
|
||||||
|
dest="file",
|
||||||
|
default=None,
|
||||||
|
help="Path to tasks.md (defaults to searching workspace)"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tasks_file = find_tasks_file(args.file)
|
||||||
|
count = update_tasks_document(tasks_file)
|
||||||
|
print(f"Successfully synchronized and updated {count} tasks in {tasks_file}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating tasks: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
74
scripts/work-on-task
Executable file
74
scripts/work-on-task
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
work-on-task
|
||||||
|
|
||||||
|
Validates a task's status for active development, displays its requirements,
|
||||||
|
and optionally transitions it to 'in_progress'.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/work-on-task EX-001
|
||||||
|
./scripts/work-on-task EX-001 --start
|
||||||
|
./scripts/work-on-task EX-001 --force
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add scripts/lib to Python path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||||
|
from task_context import fetch_task, load_get_task_module
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Validate and begin work on a task.")
|
||||||
|
parser.add_argument("task_id", help="Task number or prefixed ID (e.g., 1 or PREFIX-001)")
|
||||||
|
parser.add_argument("--start", action="store_true", help="Transition status to 'in_progress'")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Proceed even if status is not ready/pending/in_progress")
|
||||||
|
parser.add_argument("-f", "--file", dest="file", default=None, help="Path to tasks.md")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = fetch_task(args.task_id, tasks_file=args.file)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"Error fetching task {args.task_id}: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
status = task.get("status", "").strip().lower()
|
||||||
|
valid_work_statuses = ["ready", "pending", "in_progress", "in-progress"]
|
||||||
|
if status not in valid_work_statuses and not args.force:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"WARNING: Task {task['id']} is currently in '{task['status']}' status.\n"
|
||||||
|
f"Tasks in '{task['status']}' are usually not ready to be worked on directly.\n"
|
||||||
|
f"To bypass this check, pass --force.\n"
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
print(f"=== Task {task['id']}: {task['title']} ===")
|
||||||
|
print(f"Project: {task['project']}")
|
||||||
|
if task.get("epic"):
|
||||||
|
print(f"Epic: {task['epic_label']}")
|
||||||
|
print(f"Status: {task['status']}")
|
||||||
|
print(f"Type: {task['type']}")
|
||||||
|
if task.get("description"):
|
||||||
|
print("\nRequirements & Description:")
|
||||||
|
print(task["description"])
|
||||||
|
|
||||||
|
if args.start and status not in ("in_progress", "in-progress"):
|
||||||
|
mod = load_get_task_module()
|
||||||
|
update_mod = mod.load_update_tasks_module()
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
ut_script = script_dir / "update-task"
|
||||||
|
loader = importlib.machinery.SourceFileLoader("update_task_mod", str(ut_script))
|
||||||
|
spec = importlib.util.spec_from_loader("update_task_mod", loader)
|
||||||
|
ut_mod = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(ut_mod)
|
||||||
|
|
||||||
|
ut_mod.update_task_detail(task["id"], status="in_progress", tasks_file=args.file)
|
||||||
|
print(f"\n[Status updated to 'in_progress']")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user