This commit is contained in:
2026-10-08 18:29:45 -07:00
parent 8eedf03486
commit 5725de2dac
35 changed files with 3360 additions and 0 deletions

67
scripts/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Project Scripts
This directory contains repository maintenance, task management, and release automation scripts for **Old Bear Battles**.
---
## Active Task Management Tooling
All work items and backlog features are tracked directly in repository Markdown at [docs/tasks.md](file:///Users/tom/Projects/OldBearBattles/docs/tasks.md). The Python CLI scripts below provide fast query, insertion, update, and normalization capabilities:
### `get-tasks`
Lists tasks from `docs/tasks.md` with sorting, column selection, and flexible predicate filtering:
```bash
./scripts/get-tasks # List all tasks sorted by ID (desc)
./scripts/get-tasks --accept 'Status:Testing' # List tasks currently awaiting verification
./scripts/get-tasks -p # List tasks in Triage and Planning status
./scripts/get-tasks --all # Show all tasks including Done and Cancelled
./scripts/get-tasks --sort asc # Sort in ascending order
```
### `get-task <ID>`
Displays detailed information, metadata fields, and full description for a specific task ID:
```bash
./scripts/get-task OB-206
```
### `add-task <title>`
Appends a new task entry to `docs/tasks.md` with frontmatter and structured markdown anchor tags:
```bash
./scripts/add-task "Descriptive Title" -p cmn -t feature -s ready -d "Task description and criteria"
```
**Options**:
- `-p, --project`: Project code (`vtt`, `cmn`, `platform`, `shared`, `brawl`)
- `-t, --type`: Task type (`feature`, `bug`, `chore`)
- `-s, --status`: Initial status (`triage`, `planning`, `ready`, `in_progress`, `testing`, `done`, `blocked`, `cancelled`)
- `-d, --description`: Markdown description text
### `update-task <ID>`
Updates status, checks off checklist items, or appends notes to a task:
```bash
./scripts/update-task OB-206 --status testing
./scripts/update-task OB-206 --check-all
./scripts/update-task OB-206 --append-description "### Implementation Notes:\n- Added test coverage."
```
### `update-tasks`
Validates and normalizes `docs/tasks.md` tables, frontmatter, and anchor IDs:
```bash
./scripts/update-tasks
```
### `triage` / `research`
Interactive helpers for rapidly reviewing tasks in triage or conducting architectural research on backlog items.
---
## Release Tooling
- **`build-release`**: Automated release build pipeline (runs test suites, packages production build).
- **`package-release`**: Distribution packaging script for reproducible standalone releases.
---
## Note on `ot` (Open Tasks)
> [!NOTE]
> Any references to `ot` (Open Tasks) refer to an upcoming standalone task management tool that is currently under development and not yet released. In the meantime, all tasks in this repository are managed directly in [docs/tasks.md](file:///Users/tom/Projects/OldBearBattles/docs/tasks.md) using the scripts documented above.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

243
scripts/add-task Executable file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
add-task
Appends a new task to Documentation/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 editor -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()

62
scripts/build-release Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# Build and verify a production release of Open Tasks
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}/src:${PYTHONPATH:-}"
VERSION=""
OUTPUT_DIR="${REPO_ROOT}/dist"
while [[ $# -gt 0 ]]; do
case "$1" in
--version|-v)
VERSION="$2"
shift 2
;;
--output-dir|-o)
OUTPUT_DIR="$2"
shift 2
;;
*)
if [ -z "$VERSION" ]; then
VERSION="$1"
fi
shift
;;
esac
done
if [ -z "$VERSION" ]; then
VERSION=$(python3 -c "import ot; print(ot.__version__)")
fi
mkdir -p "$OUTPUT_DIR"
echo "=== 1. Running Test Suite ==="
python3 -m unittest discover -s "${REPO_ROOT}/tests"
echo "=== 2. Building Release Artifact (v${VERSION}) ==="
python3 -m ot.packaging \
--version "$VERSION" \
--src-dir "${REPO_ROOT}/src" \
--output-dir "$OUTPUT_DIR" \
--artifact-name "ot-${VERSION}.zipapp"
# Also maintain canonical ot.zipapp copy in dist/
cp "${OUTPUT_DIR}/ot-${VERSION}.zipapp" "${OUTPUT_DIR}/ot.zipapp"
echo "=== 3. Generating SHA256 Checksums ==="
cd "$OUTPUT_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "ot-${VERSION}.zipapp" "ot.zipapp" > SHA256SUMS
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "ot-${VERSION}.zipapp" "ot.zipapp" > SHA256SUMS
fi
echo "=== 4. Validating Built Release Artifact ==="
python3 "${OUTPUT_DIR}/ot-${VERSION}.zipapp" version
echo "=========================================="
echo "Release Pipeline completed successfully for v${VERSION}!"
echo "Artifacts created in ${OUTPUT_DIR}:"
ls -la "${OUTPUT_DIR}"

173
scripts/ci-build.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# ==============================================================================
# Old Bear Battles - Production Container Build & CI Publish Script
# Sourced from single source of truth: VERSION file in repository root
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
# 1. Read version from single source of truth (VERSION file in repo root)
APP_VERSION=""
if [ -f "${ROOT_DIR}/VERSION" ]; then
APP_VERSION="$(tr -d ' \r\n' < "${ROOT_DIR}/VERSION")"
fi
if [ -z "${APP_VERSION}" ]; then
APP_VERSION="0.1.0-alpha5"
fi
# 2. Get short git commit hash
GIT_COMMIT_SHORT="$(git rev-parse --short HEAD 2>/dev/null || echo "dev")"
# Default configuration (can be overridden via CLI args or environment variables)
IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io/headhunter45/}"
IMAGE_REGISTRY="$(echo "${IMAGE_REGISTRY}" | tr '[:upper:]' '[:lower:]')"
if [ -n "${IMAGE_REGISTRY}" ] && [[ "${IMAGE_REGISTRY}" != */ ]]; then
IMAGE_REGISTRY="${IMAGE_REGISTRY}/"
fi
IMAGE_TAG="${IMAGE_TAG:-${APP_VERSION}}"
DO_PUSH=false
RUN_TESTS=false
# Parse command line options
while [[ $# -gt 0 ]]; do
case "$1" in
--registry)
IMAGE_REGISTRY="$(echo "$2" | tr '[:upper:]' '[:lower:]')"
if [ -n "${IMAGE_REGISTRY}" ] && [[ "${IMAGE_REGISTRY}" != */ ]]; then
IMAGE_REGISTRY="${IMAGE_REGISTRY}/"
fi
shift 2
;;
--tag)
IMAGE_TAG="$2"
shift 2
;;
--push)
DO_PUSH=true
shift
;;
--test)
RUN_TESTS=true
shift
;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --registry <URL> Container registry prefix (default: ${IMAGE_REGISTRY})"
echo " --tag <TAG> Container image tag (default: ${IMAGE_TAG} from VERSION file)"
echo " --push Push container images to registry after building"
echo " --test Run test suites before building images"
echo " -h, --help Show this help message"
echo ""
echo "Version info:"
echo " VERSION file : ${APP_VERSION}"
echo " Git commit hash : ${GIT_COMMIT_SHORT}"
echo ""
echo "Examples:"
echo " $0"
echo " $0 --push"
echo " $0 --registry ghcr.io/headhunter45/ --tag v0.1.0 --push"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
echo "======================================================================"
echo " Old Bear Battles - Production Container Build"
echo "======================================================================"
echo " Application Ver : ${APP_VERSION} (from VERSION)"
echo " Git Commit Hash : ${GIT_COMMIT_SHORT}"
echo " Primary Tag : ${IMAGE_TAG}"
echo " Registry Prefix : ${IMAGE_REGISTRY:-<local docker>}"
echo " Push to Registry: ${DO_PUSH}"
echo " Project Root : ${ROOT_DIR}"
echo "======================================================================"
# Optional test run
if [ "${RUN_TESTS}" = true ]; then
echo ""
echo "==> Running automated test suites..."
npm test
fi
CLIENT_IMAGE="${IMAGE_REGISTRY}oldbear_client:${IMAGE_TAG}"
CLIENT_IMAGE_COMMIT="${IMAGE_REGISTRY}oldbear_client:${GIT_COMMIT_SHORT}"
CLIENT_IMAGE_LATEST="${IMAGE_REGISTRY}oldbear_client:latest"
SERVER_IMAGE="${IMAGE_REGISTRY}oldbear_server:${IMAGE_TAG}"
SERVER_IMAGE_COMMIT="${IMAGE_REGISTRY}oldbear_server:${GIT_COMMIT_SHORT}"
SERVER_IMAGE_LATEST="${IMAGE_REGISTRY}oldbear_server:latest"
NGINX_IMAGE="${IMAGE_REGISTRY}oldbear_nginx:${IMAGE_TAG}"
NGINX_IMAGE_COMMIT="${IMAGE_REGISTRY}oldbear_nginx:${GIT_COMMIT_SHORT}"
NGINX_IMAGE_LATEST="${IMAGE_REGISTRY}oldbear_nginx:latest"
echo ""
echo "==> 1/3 Building Client container (${CLIENT_IMAGE})..."
docker build \
-t "${CLIENT_IMAGE}" \
-t "${CLIENT_IMAGE_COMMIT}" \
-t "${CLIENT_IMAGE_LATEST}" \
-f docker/Dockerfile.client \
--build-arg CLIENT_PORT=80 \
--build-arg APP_VERSION="${APP_VERSION}" \
--build-arg GIT_COMMIT_HASH="${GIT_COMMIT_SHORT}" \
.
echo ""
echo "==> 2/3 Building Server container (${SERVER_IMAGE})..."
docker build \
-t "${SERVER_IMAGE}" \
-t "${SERVER_IMAGE_COMMIT}" \
-t "${SERVER_IMAGE_LATEST}" \
-f docker/Dockerfile.server \
--build-arg SERVER_PORT=3001 \
--build-arg APP_VERSION="${APP_VERSION}" \
--build-arg GIT_COMMIT_HASH="${GIT_COMMIT_SHORT}" \
.
echo ""
echo "==> 3/3 Building Nginx proxy container (${NGINX_IMAGE})..."
docker build \
-t "${NGINX_IMAGE}" \
-t "${NGINX_IMAGE_COMMIT}" \
-t "${NGINX_IMAGE_LATEST}" \
-f docker/Dockerfile.nginx \
.
echo ""
echo "======================================================================"
echo " Build Completed Successfully!"
echo " Images built:"
echo " - ${CLIENT_IMAGE}, ${CLIENT_IMAGE_COMMIT}, ${CLIENT_IMAGE_LATEST}"
echo " - ${SERVER_IMAGE}, ${SERVER_IMAGE_COMMIT}, ${SERVER_IMAGE_LATEST}"
echo " - ${NGINX_IMAGE}, ${NGINX_IMAGE_COMMIT}, ${NGINX_IMAGE_LATEST}"
echo "======================================================================"
if [ "${DO_PUSH}" = true ]; then
echo ""
echo "==> Pushing images to registry: ${IMAGE_REGISTRY}..."
docker push "${CLIENT_IMAGE}"
docker push "${CLIENT_IMAGE_COMMIT}"
docker push "${CLIENT_IMAGE_LATEST}"
docker push "${SERVER_IMAGE}"
docker push "${SERVER_IMAGE_COMMIT}"
docker push "${SERVER_IMAGE_LATEST}"
docker push "${NGINX_IMAGE}"
docker push "${NGINX_IMAGE_COMMIT}"
docker push "${NGINX_IMAGE_LATEST}"
echo ""
echo "======================================================================"
echo " All images successfully pushed to ${IMAGE_REGISTRY}!"
echo "======================================================================"
fi

83
scripts/env.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/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="$REPO_ROOT/docs/tasks.md"
# Optional document-wide prefix override; unset uses frontmatter, then OT.
# export TASK_ID_PREFIX="OT"
# Define Aliases
alias run-editor='(cd Editor/App && npm run tauri dev -- -- "../../Content/Episodes/Episode 1/Episode.xml")'
alias get-ready-tasks="$SCRIPTS_DIR/get-tasks | 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
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
get-task
Retrieves detailed information and metadata for a specific task in Documentation/Tasks.md.
Usage:
./scripts/get-task OT-018
./scripts/get-task 18 --json
./scripts/get-task OT-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
View File

@@ -0,0 +1,296 @@
#!/usr/bin/env bash
#
# get-tasks
#
# Lists tasks from Documentation/Tasks.md sorted by ID.
# Supports --accept, --reject, and --sort [asc|desc] (defaults to descending).
# Defaults to Pending 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
View File

@@ -0,0 +1 @@
"""Shared automation library for PlanB task tooling."""

Binary file not shown.

View 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]

Binary file not shown.

Binary file not shown.

View 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)

View 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}>"

View 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)

View 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()

132
scripts/lib/task_context.py Normal file
View File

@@ -0,0 +1,132 @@
import argparse
import importlib.machinery
import importlib.util
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_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."""
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']} in the PlanBForGreatJustice repository.
### 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 (GEMINI.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. Architecture Overview:
- C++ Story Engine: Engine/PlanB/Engine/ (Actions, Expressions, Sequences, GameState, StoryModel).
- Desktop Editor: Editor/App/ (Tauri + Vite + React, Sequence Simulator, Expression Graph Canvas, XML converters).
- Content: Content/Episode 1/Episode1/Episode.xml.
- Specifications: docs/Schema/ (e.g., PBScript_Specification.md, grammar files).
- Legacy Reference: Editor/Sources/ (WPF C# editor for comparison if applicable).
3. Objectives for this session:
{workflow_objective}
"""
if extra_context:
prompt += f"\n### Additional Context:\n{extra_context}\n"
prompt += f"\nBegin by reviewing the task, inspecting the relevant code, 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

8
scripts/package-release Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
# Package Open Tasks release artifact
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}/src:${PYTHONPATH:-}"
python3 -m ot.packaging "$@"

63
scripts/research Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
research
Researches a task from Documentation/Tasks.md using an AI CLI agent (Antigravity or Hermes).
Validates that the task is in 'research' status, prepares task and codebase context,
and launches the agent interactively.
Usage:
./scripts/research 037
./scripts/research OT-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 = """- Deeply investigate the technical requirements, architecture, and code models needed for this task.
- Inspect existing codebase, grammar definitions, or schemas to identify gaps or technical hurdles.
- Perform web searches or documentation lookups if external formats, libraries, or protocols are involved.
- Collaborate interactively with the user to explore technical trade-offs and possible approaches.
- Document research findings, technical recommendations, and proposed solutions.
- 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 'pending'."""
def main():
parser = argparse.ArgumentParser(
description="Research a task in Documentation/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()

View File

@@ -0,0 +1,296 @@
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 "ot"
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("OT-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"], "OT-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(), "OT-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("OT-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("OT-001"), output.index("OT-002"))
output = self.run_cli(
"get-tasks", "--all", "--accept", "Epic:Team's Release", "--select", "ID,Epic", env=env,
).stdout
self.assertIn("OT-001", output)
self.assertNotIn("OT-002", output)
self.assertNotIn("Title", output)
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:Proof of Concept", env=env).stdout
self.assertNotIn("OT-002", output)
self.assertIn("OT-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("OT-002", output)
self.assertNotIn("OT-001", output)
output = self.run_cli("get-tasks", "--all", "--reject", "Epic:poc", env=env).stdout
self.assertNotIn("OT-002", output)
self.assertIn("OT-001", output)
output = self.run_cli(
"get-tasks", "--accept", "Epic:poc,release", "--accept", "Status:Triage", env=env,
).stdout
self.assertIn("OT-002", output)
self.assertNotIn("OT-001", output)
self.assertNotIn("OT-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)
if __name__ == "__main__":
unittest.main()

63
scripts/triage Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
triage
Triages a task from Documentation/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 OT-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 codebase to understand the current implementation and scope for this task.
- Clarify ambiguities: ask the user any critical design or scoping questions interactively.
- Formulate a clear, actionable checklist of engineering deliverables (- [ ]).
- 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 'pending', or keep in 'triage'/'planning' if awaiting user decisions."""
def main():
parser = argparse.ArgumentParser(
description="Triage a task in Documentation/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
View File

@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
update-task
Modifies metadata, title, and/or description of a specific task in Documentation/Tasks.md
and automatically synchronizes the document tables and sorting.
Usage:
./scripts/update-task OT-018 --status pending
./scripts/update-task OT-018 --title "Updated Title"
./scripts/update-task OT-018 --description "New description..."
./scripts/update-task OT-018 --append-description "- [ ] Additional checklist item"
./scripts/update-task OT-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
View File

@@ -0,0 +1,574 @@
#!/usr/bin/env python3
"""
update-tasks
Synchronizes and regenerates Documentation/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. OT-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](#ot-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", "OT"))
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()

76
scripts/work-on-task Executable file
View File

@@ -0,0 +1,76 @@
#!/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 OT-001
./scripts/work-on-task OT-001 --start
./scripts/work-on-task OT-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 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 = ["pending", "in_progress"]
invalid_statuses = ["planning", "triage", "research", "done", "cancelled", "blocked"]
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 != "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()