diff --git a/.cursor/rules/cannon-eng02-context.mdc b/.cursor/rules/cannon-eng02-context.mdc new file mode 100644 index 0000000..c324741 --- /dev/null +++ b/.cursor/rules/cannon-eng02-context.mdc @@ -0,0 +1,40 @@ +--- +description: What Potato Cannon / ENG-02 left in this repo, and how to read it +globs: specs/002-smoke-build-info/**,.potato/**,.claude/** +alwaysApply: false +--- + +# What Cannon is doing to this repo + +If you see `specs/002-smoke-build-info/`, a `.potato/` folder, or branches named `potato/HOM-*`, that is not stray product work — it is evidence of ENG-02, run against HomesFlow on purpose. Do not mistake it for real product scope. + +## Short version + +**Potato Cannon** (`potato-cannon` repo) is a kanban + agent orchestrator that runs Claude sessions through workflow phases with human approval gates. Rik uses it as the delivery mechanism for HomesFlow tickets going forward. + +**Golden Thread** is HomesFlow's layer on Spec-Kit: immutable IDs (`FR-`/`AC-` per `traceability.md` §3) and Gate 1 / Gate 2 (`traceability.md` §6). Spec-Kit itself does not know about it. + +**ENG-02** (spudnik) hypothesizes Cannon can drive HomesFlow's *real* installed spec-kit (`.claude/skills/speckit-*`) plus Golden Thread — not a hand-rolled approximation — with a kanban view and approval gates. + +## Reference example: HOM-2 + +HOM-2 ("Stand up the Walking Skeleton") was the first end-to-end run of the `homesflow-speckit` template. Deliberately trivial (`FR-SMOKE-01`, `SMOKE` domain) — *not* real product scope, exempted from PRD-sourcing for that ticket only. Done. Read `specs/002-smoke-build-info/` for what a Cannon-driven ticket produces. + +Notable from that run: real skills invoked (not reimplemented); Gate 1 caught `plan.md` self-certifying IDs missing from `spec.md`; a mid-run `chat_ask` was answered via Cannon's dashboard and the session resumed. + +## Design decisions (Cannon side) + +- Phase gates only where `traceability.md` §6 says work stops (before/after implement). Specify → Clarify → Plan → Tasks → Analyze = one continuous resumed session. +- No adversarial retry loops (`ralphLoop`/`taskLoop`). Real `speckit-implement` / `speckit-converge` already own that judgment. +- Promote/demote resumes the Claude session with a digest of new comments/Q&A/artifacts — not a full context rebuild. + +## Still open + +**Thread Cannon** (fork Cannon into a Spec-Kit + Golden Thread–specific tool) was deferred until the walking skeleton finished; that decision is live again, not settled. + +## Go deeper + +- `potato-cannon` + its CLAUDE.md +- `potato-cannon/apps/daemon/templates/workflows/homesflow-speckit/` +- spudnik `OBJECTIVES.md` (ENG-02), `docs/field-notes-pipeline.md` (FN-05) +- This repo's `traceability.md` diff --git a/.cursor/rules/newbie-homesflow.mdc b/.cursor/rules/newbie-homesflow.mdc new file mode 100644 index 0000000..7b98f9a --- /dev/null +++ b/.cursor/rules/newbie-homesflow.mdc @@ -0,0 +1,64 @@ +--- +description: HomesFlow trial engineer orientation — Spec Kit, Golden Thread, craft/PR hygiene +alwaysApply: true +--- + +# Context Primer — HomesFlow trial engineer + +You are joining HomesFlow as a trial collaborator. Your job is to deliver a thin slice the HomesFlow way: Spec Kit discipline, Golden Thread integrity, then craft/PR hygiene. Do not try to match prior agent diffs or invent product scope. + +## How to work with Rik +- High-signal, direct, verify before asserting. +- Self-serve from the repo. Ask only when the PRD is silent or something is broken/off-repo. + +## Read first (in order) +1. `CLAUDE.md` — entry + hard rules +2. `.specify/memory/constitution.md` — process/architecture law (Hierarchy of Truth) +3. `traceability.md` — Gate 2 mechanics +4. `HomesFlow.prd.md` — **product scope only** +5. `specs/001-mvp/dev-notes.md` — env + operational state +6. `specs/001-mvp/craft-conventions.md` — craft + delivery + +## Hard rules +- **Product scope** comes only from `HomesFlow.prd.md`. Do not invent, extrapolate, or assume requirements. If the PRD is silent, ask or propose a PRD change — do not ship guessed behavior. +- **Process/architecture** comes from the constitution. Do not invent product behavior from the constitution, glossary, story map, or craft docs. +- **Story map** (StoriesOnBoard) is a human planning aid only — not Gate-2-enforced. Promote into the PRD when it becomes product truth. +- **Gate 2** proves the PRD is *true of the system* (IDs ↔ `@covers` ↔ `test_AC_*` or tracked debt). It does not prove the PRD is the right product. + +## Spec Kit loop (expected) +For the assigned thin slice: +1. Confirm the authoritative IDs in `HomesFlow.prd.md` (Rik amends the PRD first if needed). +2. Fan into `specs/001-mvp/` (spec → plan → tasks) without minting new requirement IDs in the feature folder. +3. Implement only what tasks authorize; keep `Traces:` on every task. +4. Prove with `@covers` and `test_AC_*` (or leave unchecked tasks as tracked debt — never silent gaps). +5. Run `bash scripts/check-traceability.sh` before you call the slice done. + +## Golden Thread (commands) +```bash +bash scripts/check-traceability.sh # must PASS +bash scripts/check-traceability.sh --json # per-ID detail +``` +Coverage matrix (`coverage.md`) is a portfolio snapshot; CI enforces the gate script, not file freshness. + +## Craft / PR hygiene +```text +feature branch → PR → craft-gate (+ Sonar) green → merge to main +``` +- Branch naming for cloud agents: `cursor/-7716` +- Fill `.github/pull_request_template.md` (task IDs, Traces, Gate 2 evidence) +- Do not push product/craft changes straight to `main` +- Follow `craft-conventions.md` (SwiftLint, policy objects where logic is testable) + +## Your trial assignment +Rik will give you **one micro acceptance criterion** newly added to the PRD (atomic, unit-testable). Take it through Spec Kit → implementation → Gate 2 green → PR. + +**Success looks like:** clean Spec Kit artifacts, unbroken golden thread for that ID, and a reviewable PR. +**Non-goals:** matching a prior check-in’s code; rebuilding the thread visualizer; expanding scope “while you’re in there.” + +## Local assumptions (confirm if relevant) +- Supabase local: `127.0.0.1:54321`; `Secrets.xcconfig` from `.example` +- Xcode: `cd ios && xcodegen generate` +- Paths: `ios/HomesFlow/`, `ios/HomesFlowTests/` + +## Starting the session +Confirm: (1) you have the micro-AC ID and PRD text, (2) local vs cloud env, (3) any blockers not in `dev-notes`. Then proceed. diff --git a/.potato/worktrees/HOM-1 b/.potato/worktrees/HOM-1 new file mode 160000 index 0000000..9bd6ae5 --- /dev/null +++ b/.potato/worktrees/HOM-1 @@ -0,0 +1 @@ +Subproject commit 9bd6ae50b9968f4d6bdfb7c8239c2fbb65077676 diff --git a/.potato/worktrees/HOM-2 b/.potato/worktrees/HOM-2 new file mode 160000 index 0000000..9bd6ae5 --- /dev/null +++ b/.potato/worktrees/HOM-2 @@ -0,0 +1 @@ +Subproject commit 9bd6ae50b9968f4d6bdfb7c8239c2fbb65077676 diff --git a/.potato/worktrees/HOM-3 b/.potato/worktrees/HOM-3 new file mode 160000 index 0000000..9bd6ae5 --- /dev/null +++ b/.potato/worktrees/HOM-3 @@ -0,0 +1 @@ +Subproject commit 9bd6ae50b9968f4d6bdfb7c8239c2fbb65077676 diff --git a/.specify/extensions.yml b/.specify/extensions.yml index 5415714..be8dcc9 100644 --- a/.specify/extensions.yml +++ b/.specify/extensions.yml @@ -1,23 +1,14 @@ installed: -- agent-context +- clewseau-gate settings: auto_execute_hooks: true hooks: - after_specify: - - extension: agent-context - command: speckit.agent-context.update + after_implement: + - extension: clewseau-gate + command: speckit.clewseau-gate.check enabled: true optional: true priority: 10 - prompt: Execute speckit.agent-context.update? - description: Refresh agent context after specification - condition: null - after_plan: - - extension: agent-context - command: speckit.agent-context.update - enabled: true - optional: true - priority: 10 - prompt: Execute speckit.agent-context.update? - description: Refresh agent context after planning + prompt: Run Clewseau Gate 2 traceability check? + description: Refuse silent gaps after implementation; refresh clew.json condition: null diff --git a/.specify/extensions/.registry b/.specify/extensions/.registry index a19cfa2..7ab49e9 100644 --- a/.specify/extensions/.registry +++ b/.specify/extensions/.registry @@ -1,19 +1,19 @@ { "schema_version": "1.0", "extensions": { - "agent-context": { - "version": "1.0.0", + "clewseau-gate": { + "version": "0.2.0", "source": "local", - "manifest_hash": "sha256:9a1dc02d2d0139bb03860392ecacef79183be2c442feda2f9ccaa4e5907b1e47", + "manifest_hash": "sha256:c540ad473eb810d0952c51812571222b0d45b9f7aa5828d252d0cb5809110c89", "enabled": true, "priority": 10, "registered_commands": { "claude": [ - "speckit.agent-context.update" + "speckit.clewseau-gate.check" ] }, "registered_skills": [], - "installed_at": "2026-06-26T14:27:06.775090+00:00" + "installed_at": "2026-08-06T20:51:47.837406+00:00" } } } \ No newline at end of file diff --git a/.specify/extensions/agent-context/README.md b/.specify/extensions/agent-context/README.md deleted file mode 100644 index 091e2b4..0000000 --- a/.specify/extensions/agent-context/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Coding Agent Context Extension - -This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration. - -It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `` / ``). - -## Why an extension? - -Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users: - -- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file. -- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value. -- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`. -- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). - -## Commands - -| Command | Description | -|---------|-------------| -| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. | - -## Configuration - -All configuration flows through the extension's own config file at -`.specify/extensions/agent-context/agent-context-config.yml`: - -```yaml -# Path to the coding agent context file managed by this extension -context_file: CLAUDE.md - -# Optional list of coding agent context files to manage together. -# When non-empty, this takes precedence over context_file. -context_files: - - AGENTS.md - - CLAUDE.md - -# Delimiters for the managed Spec Kit section -context_markers: - start: "" - end: "" -``` - -- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. -- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected. -- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. - -## Requirements - -The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available). - -PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run: - -```bash -pip install pyyaml -# or target the specific interpreter Spec Kit uses: -/path/to/speckit-python -m pip install pyyaml -``` - -## Disable - -```bash -specify extension disable agent-context -``` - -When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). -Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out. diff --git a/.specify/extensions/agent-context/agent-context-config.yml b/.specify/extensions/agent-context/agent-context-config.yml deleted file mode 100644 index d55ff7c..0000000 --- a/.specify/extensions/agent-context/agent-context-config.yml +++ /dev/null @@ -1,5 +0,0 @@ -context_file: CLAUDE.md -context_files: [] -context_markers: - start: - end: diff --git a/.specify/extensions/agent-context/commands/speckit.agent-context.update.md b/.specify/extensions/agent-context/commands/speckit.agent-context.update.md deleted file mode 100644 index a654eb5..0000000 --- a/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: "Refresh the managed Spec Kit section in coding agent context file(s)" ---- - -# Update Coding Agent Context - -Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`). - -## Behavior - -The script reads the agent-context extension config at -`.specify/extensions/agent-context/agent-context-config.yml` to discover: - -- `context_file` — the path of the coding agent context file to manage. -- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`. -- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `` and `` when the field is missing. - -It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs//plan.md`). - -If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected. - -## Execution - -- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]` -- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]` - -When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`. diff --git a/.specify/extensions/agent-context/extension.yml b/.specify/extensions/agent-context/extension.yml deleted file mode 100644 index 191069e..0000000 --- a/.specify/extensions/agent-context/extension.yml +++ /dev/null @@ -1,34 +0,0 @@ -schema_version: "1.0" - -extension: - id: agent-context - name: "Coding Agent Context" - version: "1.0.0" - description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers" - author: spec-kit-core - repository: https://github.com/github/spec-kit - license: MIT - -requires: - speckit_version: ">=0.2.0" - -provides: - commands: - - name: speckit.agent-context.update - file: commands/speckit.agent-context.update.md - description: "Refresh the managed Spec Kit section in the coding agent context file" - -hooks: - after_specify: - command: speckit.agent-context.update - optional: true - description: "Refresh agent context after specification" - after_plan: - command: speckit.agent-context.update - optional: true - description: "Refresh agent context after planning" - -tags: - - "agent" - - "context" - - "core" diff --git a/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh b/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh deleted file mode 100755 index 9d57b08..0000000 --- a/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env bash -# update-agent-context.sh -# -# Refresh the managed Spec Kit section in the coding agent's context file(s) -# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). -# -# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the -# agent-context extension config: -# .specify/extensions/agent-context/agent-context-config.yml -# -# Usage: update-agent-context.sh [plan_path] -# -# When `plan_path` is omitted, the script picks the most recently modified -# `specs/*/plan.md` if any exist, otherwise emits the section without a -# concrete plan path. - -set -euo pipefail - -PROJECT_ROOT="$(pwd)" -EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" -DEFAULT_START="" -DEFAULT_END="" - -if [[ ! -f "$EXT_CONFIG" ]]; then - echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2 - exit 0 -fi - -# Locate a Python 3 interpreter with PyYAML available. -_python="" -_python_candidates=() -[[ -n "${SPECKIT_PYTHON:-}" ]] && _python_candidates+=("$SPECKIT_PYTHON") -_python_candidates+=("python3" "python") -for _candidate in "${_python_candidates[@]}"; do - if command -v "$_candidate" >/dev/null 2>&1 \ - && "$_candidate" - <<'PY' >/dev/null 2>&1 -import sys -try: - import yaml # noqa: F401 -except ImportError: - sys.exit(1) -sys.exit(0 if sys.version_info[0] == 3 else 1) -PY - then - _python="$_candidate" - break - fi -done -unset _candidate _python_candidates - -if [[ -z "$_python" ]]; then - echo "agent-context: Python 3 with PyYAML not found on PATH; skipping update." >&2 - echo " To resolve: pip install pyyaml (or install it into the environment used by python3)." >&2 - exit 0 -fi -_case_insensitive_context_files=0 -case "$(uname -s 2>/dev/null || true)" in - MINGW*|MSYS*|CYGWIN*) _case_insensitive_context_files=1 ;; -esac - -# Parse extension config once; emit context files as JSON, followed by marker strings. -if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY' -import json -import sys -try: - import yaml -except ImportError: - print( - "agent-context: PyYAML is required to parse extension config but is not available " - "in the current Python environment.\n" - " To resolve: pip install pyyaml (or install it into the environment used by python3).\n" - " Context file will not be updated until PyYAML is importable.", - file=sys.stderr, - ) - sys.exit(2) -try: - with open(sys.argv[1], "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) -except Exception as exc: - print( - f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", - file=sys.stderr, - ) - sys.exit(2) -if not isinstance(data, dict): - data = {} -def get_str(obj, *keys): - node = obj - for k in keys: - if isinstance(node, dict) and k in node: - node = node[k] - else: - return "" - return node if isinstance(node, str) else "" -context_files = [] -seen_context_files = set() -case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin")) -raw_files = data.get("context_files") -if isinstance(raw_files, list): - for value in raw_files: - if not isinstance(value, str): - continue - candidate = value.strip() - if not candidate: - continue - key = candidate.casefold() if case_insensitive else candidate - if key in seen_context_files: - continue - context_files.append(candidate) - seen_context_files.add(key) -if not context_files: - raw_file = get_str(data, "context_file") - candidate = raw_file.strip() - if candidate: - context_files.append(candidate) -print(json.dumps(context_files)) -print(get_str(data, "context_markers", "start")) -print(get_str(data, "context_markers", "end")) -PY -)"; then - echo "agent-context: skipping update (see above for details)." >&2 - exit 0 -fi - -_opts_lines=() -while IFS= read -r _line || [[ -n "$_line" ]]; do - _opts_lines+=("$_line") -done < <(printf '%s\n' "$_raw_opts") -if (( ${#_opts_lines[@]} < 3 )); then - echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 - exit 0 -fi -CONTEXT_FILES_JSON="${_opts_lines[0]}" -MARKER_START="${_opts_lines[1]}" -MARKER_END="${_opts_lines[2]}" - -if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY' -import json -import sys -try: - data = json.loads(sys.argv[1]) -except Exception: - data = [] -if not isinstance(data, list): - data = [] -for value in data: - if isinstance(value, str) and value: - print(value) -PY -)"; then - echo "agent-context: malformed context_files parser output; skipping update." >&2 - exit 0 -fi - -CONTEXT_FILES=() -while IFS= read -r _line || [[ -n "$_line" ]]; do - [[ -n "$_line" ]] && CONTEXT_FILES+=("$_line") -done < <(printf '%s\n' "$_context_files_raw") - -if (( ${#CONTEXT_FILES[@]} == 0 )); then - echo "agent-context: context_files/context_file not set in extension config; nothing to do." >&2 - exit 0 -fi - -for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do - # Reject absolute paths, backslash separators, and '..' path segments in context files - if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then - echo "agent-context: context files must be project-relative paths; got '$CONTEXT_FILE'." >&2 - exit 1 - fi - if [[ "$CONTEXT_FILE" == *\\* ]]; then - echo "agent-context: context files must not contain backslash separators; got '$CONTEXT_FILE'." >&2 - exit 1 - fi - IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE" - for _seg in "${_cf_parts[@]}"; do - if [[ "$_seg" == ".." ]]; then - echo "agent-context: context files must not contain '..' path segments; got '$CONTEXT_FILE'." >&2 - exit 1 - fi - done - if ! "$_python" - "$PROJECT_ROOT" "$CONTEXT_FILE" <<'PY' -import sys -from pathlib import Path - -root = Path(sys.argv[1]).resolve() -target = (root / sys.argv[2]).resolve(strict=False) -try: - target.relative_to(root) -except ValueError: - sys.exit(1) -PY - then - echo "agent-context: context file path resolves outside the project root; got '$CONTEXT_FILE'." >&2 - exit 1 - fi -done -unset _cf_parts _seg - -[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" -[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" - -PLAN_PATH="${1:-}" -if [[ -z "$PLAN_PATH" ]]; then - # Pick the most recently modified plan.md one level deep (specs//plan.md). - # Use find + sort by modification time to avoid ls/head fragility with - # spaces in paths or SIGPIPE from pipefail. - _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' -import sys, os -from pathlib import Path -specs = Path(sys.argv[1]) / "specs" -plans = sorted( - specs.glob("*/plan.md"), - key=lambda p: p.stat().st_mtime, - reverse=True, -) -print(plans[0] if plans else "") -PY -)" - if [[ -n "$_plan_abs" ]]; then - PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" - fi -fi - -# Build the managed section -TMP_SECTION="$(mktemp)" -trap 'rm -f "$TMP_SECTION"' EXIT -{ - echo "$MARKER_START" - echo "For additional context about technologies to be used, project structure," - echo "shell commands, and other important information, read the current plan" - if [[ -n "$PLAN_PATH" ]]; then - echo "at $PLAN_PATH" - fi - echo "$MARKER_END" -} > "$TMP_SECTION" - -for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do - CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE" - mkdir -p "$(dirname "$CTX_PATH")" - - "$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' -import sys, os -ctx_path, start, end, section_path = sys.argv[1:5] -with open(section_path, "r", encoding="utf-8") as fh: - section = fh.read().rstrip("\n") + "\n" - -if os.path.exists(ctx_path): - with open(ctx_path, "r", encoding="utf-8-sig") as fh: - content = fh.read() - s = content.find(start) - e = content.find(end, s if s != -1 else 0) - if s != -1 and e != -1 and e > s: - end_of_marker = e + len(end) - if end_of_marker < len(content) and content[end_of_marker] == "\r": - end_of_marker += 1 - if end_of_marker < len(content) and content[end_of_marker] == "\n": - end_of_marker += 1 - new_content = content[:s] + section + content[end_of_marker:] - elif s != -1: - new_content = content[:s] + section - elif e != -1: - end_of_marker = e + len(end) - if end_of_marker < len(content) and content[end_of_marker] == "\r": - end_of_marker += 1 - if end_of_marker < len(content) and content[end_of_marker] == "\n": - end_of_marker += 1 - new_content = section + content[end_of_marker:] - else: - if content and not content.endswith("\n"): - content += "\n" - new_content = (content + "\n" + section) if content else section -else: - new_content = section - -new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") -with open(ctx_path, "wb") as fh: - fh.write(new_content.encode("utf-8")) -PY - - echo "agent-context: updated $CONTEXT_FILE" -done diff --git a/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index d31fcd6..0000000 --- a/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env pwsh -# update-agent-context.ps1 -# -# Refresh the managed Spec Kit section in the coding agent's context file(s) -# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). -# -# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the -# agent-context extension config: -# .specify/extensions/agent-context/agent-context-config.yml -# -# Usage: update-agent-context.ps1 [plan_path] - -[CmdletBinding()] -param( - [Parameter(Position = 0)] - [string]$PlanPath -) - -function Get-ConfigValue { - param( - [AllowNull()][object]$Object, - [Parameter(Mandatory = $true)][string]$Key - ) - - if ($null -eq $Object) { - return $null - } - if ($Object -is [System.Collections.IDictionary]) { - return $Object[$Key] - } - $prop = $Object.PSObject.Properties[$Key] - if ($prop) { - return $prop.Value - } - return $null -} - -function Test-ConfigObject { - param( - [AllowNull()][object]$Object - ) - - if ($null -eq $Object) { - return $false - } - if ($Object -is [System.Collections.IDictionary]) { - return $true - } - if ($Object -is [System.Management.Automation.PSCustomObject]) { - return $true - } - return $false -} - -function Resolve-ContextPath { - param( - [Parameter(Mandatory = $true)][string]$Root, - [Parameter(Mandatory = $true)][string]$RelativePath - ) - - $rootFull = [System.IO.Path]::GetFullPath($Root) - $segments = $RelativePath -split '/' - $resolved = $rootFull - - foreach ($segment in $segments) { - if ([string]::IsNullOrWhiteSpace($segment) -or $segment -eq '.') { - continue - } - - $candidate = [System.IO.Path]::GetFullPath((Join-Path $resolved $segment)) - if (Test-Path -LiteralPath $candidate) { - $item = Get-Item -LiteralPath $candidate -Force - if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { - $target = $item.Target - if ($target -is [System.Array]) { - $target = $target[0] - } - if ($target) { - if ([System.IO.Path]::IsPathRooted($target)) { - $candidate = [System.IO.Path]::GetFullPath($target) - } else { - $candidate = [System.IO.Path]::GetFullPath( - (Join-Path (Split-Path -Parent $candidate) $target) - ) - } - } - } - } - $resolved = $candidate - } - - return $resolved -} - -function Test-IsSubPath { - param( - [Parameter(Mandatory = $true)][string]$Root, - [Parameter(Mandatory = $true)][string]$Path - ) - - $comparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { - [System.StringComparison]::OrdinalIgnoreCase - } else { - [System.StringComparison]::Ordinal - } - $rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd( - [System.IO.Path]::DirectorySeparatorChar, - [System.IO.Path]::AltDirectorySeparatorChar - ) - $pathFull = [System.IO.Path]::GetFullPath($Path) - return $pathFull.Equals($rootFull, $comparison) -or - $pathFull.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar, $comparison) -} - -$ErrorActionPreference = 'Stop' -$DefaultStart = '' -$DefaultEnd = '' -$ProjectRoot = (Get-Location).Path -$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml' - -if (-not (Test-Path -LiteralPath $ExtConfig)) { - Write-Warning "agent-context: $ExtConfig not found; nothing to do." - exit 0 -} - -$Options = $null -if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { - try { - $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop - } catch { - # fall through to Python fallback - } -} - -if ($null -eq $Options) { - # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. - $pythonCmd = $null - $pythonCandidates = @() - if ($env:SPECKIT_PYTHON) { - $pythonCandidates += $env:SPECKIT_PYTHON - } - $pythonCandidates += @('python3', 'python') - foreach ($candidate in $pythonCandidates) { - if (Get-Command $candidate -ErrorAction SilentlyContinue) { - # Verify it is Python 3 with PyYAML available. - $null = & $candidate -c "import sys; import yaml; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null - if ($LASTEXITCODE -eq 0) { - $pythonCmd = $candidate - break - } - } - } - - if ($pythonCmd) { - $pyScript = $null - try { - $pyScript = [System.IO.Path]::GetTempFileName() - Set-Content -LiteralPath $pyScript -Encoding UTF8 -Value @' -import json -import sys -try: - import yaml -except ImportError: - print( - "agent-context: PyYAML is required to parse extension config; cannot update context.", - file=sys.stderr, - ) - sys.exit(2) - -try: - with open(sys.argv[1], "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) -except Exception as exc: - print( - f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", - file=sys.stderr, - ) - sys.exit(2) - -if not isinstance(data, dict): - data = {} - -print(json.dumps(data)) -'@ - $jsonOut = & $pythonCmd $pyScript $ExtConfig - if ($LASTEXITCODE -eq 0 -and $jsonOut) { - $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop - } - } catch { - $Options = $null - } finally { - if ($pyScript -and (Test-Path -LiteralPath $pyScript)) { - Remove-Item -LiteralPath $pyScript -Force -ErrorAction SilentlyContinue - } - } - } - - if (-not $Options) { - Write-Warning "agent-context: unable to parse $ExtConfig; skipping update." - exit 0 - } -} - -if (-not (Test-ConfigObject -Object $Options)) { - Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update." - exit 0 -} - -$ConfiguredContextFiles = Get-ConfigValue -Object $Options -Key 'context_files' -$ContextFiles = @() -if ($null -ne $ConfiguredContextFiles) { - foreach ($item in @($ConfiguredContextFiles)) { - if ($item -is [string] -and -not [string]::IsNullOrWhiteSpace($item)) { - $ContextFiles += $item.Trim() - } - } -} -if ($ContextFiles.Count -eq 0) { - $ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' - if ($ContextFile -is [string] -and -not [string]::IsNullOrWhiteSpace($ContextFile)) { - $ContextFiles += $ContextFile.Trim() - } -} -$pathComparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { - [System.StringComparer]::OrdinalIgnoreCase -} else { - [System.StringComparer]::Ordinal -} -$seenContextFiles = [System.Collections.Generic.HashSet[string]]::new($pathComparison) -$dedupedContextFiles = @() -foreach ($ContextFile in $ContextFiles) { - if ($seenContextFiles.Add($ContextFile)) { - $dedupedContextFiles += $ContextFile - } -} -$ContextFiles = $dedupedContextFiles -if ($ContextFiles.Count -eq 0) { - Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.' - exit 0 -} - -foreach ($ContextFile in $ContextFiles) { - # Reject absolute paths, drive-qualified paths, backslash separators, and '..' path segments in context files - if ($ContextFile -match '^[A-Za-z]:') { - Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'." - exit 1 - } - if ([System.IO.Path]::IsPathRooted($ContextFile)) { - Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'." - exit 1 - } - if ($ContextFile.Contains('\')) { - Write-Warning "agent-context: context files must not contain backslash separators; got '$ContextFile'." - exit 1 - } - $cfSegments = $ContextFile -split '[/\\]' - if ($cfSegments -contains '..') { - Write-Warning "agent-context: context files must not contain '..' path segments; got '$ContextFile'." - exit 1 - } - $resolvedTarget = Resolve-ContextPath -Root $ProjectRoot -RelativePath $ContextFile - if (-not (Test-IsSubPath -Root $ProjectRoot -Path $resolvedTarget)) { - Write-Warning "agent-context: context file path resolves outside the project root; got '$ContextFile'." - exit 1 - } -} - -$MarkerStart = $DefaultStart -$MarkerEnd = $DefaultEnd -$cm = Get-ConfigValue -Object $Options -Key 'context_markers' -if ($cm) { - $cmStart = Get-ConfigValue -Object $cm -Key 'start' - if ($cmStart -is [string] -and $cmStart) { - $MarkerStart = $cmStart - } - $cmEnd = Get-ConfigValue -Object $cm -Key 'end' - if ($cmEnd -is [string] -and $cmEnd) { - $MarkerEnd = $cmEnd - } -} - -if (-not $PlanPath) { - # Discover plan.md exactly one level deep (specs//plan.md), - # matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under - # $ErrorActionPreference = 'Stop' don't abort the script. - try { - $specsDir = Join-Path $ProjectRoot 'specs' - $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | - ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | - Where-Object { $_ } | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - if ($candidate) { - $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') - } - } catch { - # Non-fatal: continue without a plan path. - } -} - -$lines = @($MarkerStart, - 'For additional context about technologies to be used, project structure,', - 'shell commands, and other important information, read the current plan') -if ($PlanPath) { - $lines += "at $PlanPath" -} -$lines += $MarkerEnd -$Section = ($lines -join "`n") + "`n" - -foreach ($ContextFile in $ContextFiles) { - $CtxPath = Join-Path $ProjectRoot $ContextFile - $CtxDir = Split-Path -Parent $CtxPath - if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) { - New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null - } - - if (Test-Path -LiteralPath $CtxPath) { - $rawBytes = [System.IO.File]::ReadAllBytes($CtxPath) - # Strip UTF-8 BOM if present - if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) { - $content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3) - } else { - $content = [System.Text.Encoding]::UTF8.GetString($rawBytes) - } - - $s = $content.IndexOf($MarkerStart) - $e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) } - - if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) { - $endOfMarker = $e + $MarkerEnd.Length - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } - $newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker) - } elseif ($s -ge 0) { - $newContent = $content.Substring(0, $s) + $Section - } elseif ($e -ge 0) { - $endOfMarker = $e + $MarkerEnd.Length - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } - $newContent = $Section + $content.Substring($endOfMarker) - } else { - if ($content -and -not $content.EndsWith("`n")) { $content += "`n" } - if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section } - } - } else { - $newContent = $Section - } - - $newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") - [System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) - - Write-Host "agent-context: updated $ContextFile" -} diff --git a/.specify/extensions/clewseau-gate/.specify-dev/agent-commands/claude/speckit-clewseau-gate-check/SKILL.md b/.specify/extensions/clewseau-gate/.specify-dev/agent-commands/claude/speckit-clewseau-gate-check/SKILL.md new file mode 100644 index 0000000..27f0619 --- /dev/null +++ b/.specify/extensions/clewseau-gate/.specify-dev/agent-commands/claude/speckit-clewseau-gate-check/SKILL.md @@ -0,0 +1,34 @@ +--- +name: speckit-clewseau-gate-check +description: Run Clewseau Gate 2 — fail on silent gaps; emit clew.json +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + author: github-spec-kit + source: clewseau-gate:commands/speckit.clewseau-gate.check.md +--- + +# Clewseau Gate 2 check + +Run the portable Gate 2 script shipped with this extension. It checks that: + +- registry IDs ≡ IDs in specs ≡ IDs in tasks (**exact-set** drift), +- durable IDs are either verified (named proof) or tracked as debt (open task) for **ACs**, +- coverage annotations / test-encoded IDs are not orphans, +- checkbox tasks declare `Traces:`, + +and **always writes a clew** (default `clew.json`, path configurable) — the Clewseau matrix. The clew is written even when the gate fails, so GAPs and `gate.failures` are visible. + +`clew.json` is Clewseau-native (`format: "clew"`, schemaVersion 3). It is not ReqIF/OSLC. clewloupe (the viewer) consumes this file; it does not re-scan the target. + +## Steps + +1. Confirm `.specify/extensions/clewseau-gate/clewseau-gate-config.yml` exists (copy from `config-template.yml` if missing) and points at this project's registry, specs, tasks, and source/test trees. Set `clew_path` if you do not want `clew.json` at the project root. +2. From the project root, run: + +```bash +bash .specify/extensions/clewseau-gate/scripts/check-traceability.sh +``` + +3. Report the script's exit code, any `FAIL:` lines, and confirm `clew.json` (or configured `clew_path`) was written. +4. Do **not** weaken the gate. If something is unfinished, it belongs as tracked debt (unchecked task with `Traces:`), not as a silenced gap. +5. Reminder: **verified** means a named proof exists — not that a full suite was asserted green by this script. \ No newline at end of file diff --git a/.specify/extensions/clewseau-gate/README.md b/.specify/extensions/clewseau-gate/README.md new file mode 100644 index 0000000..2bacdfb --- /dev/null +++ b/.specify/extensions/clewseau-gate/README.md @@ -0,0 +1,11 @@ +# Clewseau Gate + +Gate 2: compare the ID registry to specs, tasks, coverage annotations, and test names. **Exact-set** registry ≡ specs ≡ tasks. Silent AC gaps and untraced scope fail. **Always writes a clew** (default `clew.json`, configurable via `clew_path`) for clewloupe or any matrix consumer. + +Install: + +```bash +specify extension add --dev /path/to/clewseau/extensions/clewseau-gate +``` + +Install scaffolds `clewseau-gate-config.yml` from `config-template.yml`. Edit `registry`, `clew_path`, `specs`, `tasks`, `src_globs`, and `test_globs` for your repo. diff --git a/.specify/extensions/clewseau-gate/clewseau-gate-config.yml b/.specify/extensions/clewseau-gate/clewseau-gate-config.yml new file mode 100644 index 0000000..1d9652a --- /dev/null +++ b/.specify/extensions/clewseau-gate/clewseau-gate-config.yml @@ -0,0 +1,31 @@ +# Clewseau Gate config (edit after install) + +# Authoritative ID registry (usually the PRD) +registry: "HomesFlow.prd.md" + +# Optional display name in clew.json (defaults to project directory name) +target_name: "HomesFlow" + +# Where Gate 2 writes the clew (Clewseau native matrix; format: clew) +clew_path: "clew.json" + +# Spec Kit feature artifacts (globs relative to project root) +specs: "specs/**/spec.md" +tasks: "specs/**/tasks.md" + +# Implementation + test trees (adjust per language/layout) +src_globs: + - "src/**" + - "ios/**/*.swift" +test_globs: + - "tests/**" + - "ios/**/*Tests*/**/*.swift" + +# ID grammar (HomesFlow-compatible default) +id_regex: "(FR|NFR|AC|US)-[A-Z][A-Z0-9]{1,5}-[0-9]{2,}[a-z]?" + +# How coverage annotations appear in source +covers_regex: "@covers[[:space:]]+.*" + +# How AC IDs appear inside test names (e.g. test_AC_SYNC_04_...) +test_ac_regex: "AC_[A-Z][A-Z0-9]{1,5}_[0-9]{2,}[a-z]?" diff --git a/.specify/extensions/clewseau-gate/commands/speckit.clewseau-gate.check.md b/.specify/extensions/clewseau-gate/commands/speckit.clewseau-gate.check.md new file mode 100644 index 0000000..53a79d9 --- /dev/null +++ b/.specify/extensions/clewseau-gate/commands/speckit.clewseau-gate.check.md @@ -0,0 +1,29 @@ +--- +description: Run Clewseau Gate 2 — fail on silent gaps; emit clew.json +--- + +# Clewseau Gate 2 check + +Run the portable Gate 2 script shipped with this extension. It checks that: + +- registry IDs ≡ IDs in specs ≡ IDs in tasks (**exact-set** drift), +- durable IDs are either verified (named proof) or tracked as debt (open task) for **ACs**, +- coverage annotations / test-encoded IDs are not orphans, +- checkbox tasks declare `Traces:`, + +and **always writes a clew** (default `clew.json`, path configurable) — the Clewseau matrix. The clew is written even when the gate fails, so GAPs and `gate.failures` are visible. + +`clew.json` is Clewseau-native (`format: "clew"`, schemaVersion 3). It is not ReqIF/OSLC. clewloupe (the viewer) consumes this file; it does not re-scan the target. + +## Steps + +1. Confirm `.specify/extensions/clewseau-gate/clewseau-gate-config.yml` exists (copy from `config-template.yml` if missing) and points at this project's registry, specs, tasks, and source/test trees. Set `clew_path` if you do not want `clew.json` at the project root. +2. From the project root, run: + +```bash +bash .specify/extensions/clewseau-gate/scripts/check-traceability.sh +``` + +3. Report the script's exit code, any `FAIL:` lines, and confirm `clew.json` (or configured `clew_path`) was written. +4. Do **not** weaken the gate. If something is unfinished, it belongs as tracked debt (unchecked task with `Traces:`), not as a silenced gap. +5. Reminder: **verified** means a named proof exists — not that a full suite was asserted green by this script. diff --git a/.specify/extensions/clewseau-gate/config-template.yml b/.specify/extensions/clewseau-gate/config-template.yml new file mode 100644 index 0000000..71d53f9 --- /dev/null +++ b/.specify/extensions/clewseau-gate/config-template.yml @@ -0,0 +1,31 @@ +# Clewseau Gate config (edit after install) + +# Authoritative ID registry (usually the PRD) +registry: "PRD.md" + +# Optional display name in clew.json (defaults to project directory name) +target_name: "" + +# Where Gate 2 writes the clew (Clewseau native matrix; format: clew) +clew_path: "clew.json" + +# Spec Kit feature artifacts (globs relative to project root) +specs: "specs/**/spec.md" +tasks: "specs/**/tasks.md" + +# Implementation + test trees (adjust per language/layout) +src_globs: + - "src/**" + - "ios/**/*.swift" +test_globs: + - "tests/**" + - "ios/**/*Tests*/**/*.swift" + +# ID grammar (HomesFlow-compatible default) +id_regex: "(FR|NFR|AC|US)-[A-Z][A-Z0-9]{1,5}-[0-9]{2,}[a-z]?" + +# How coverage annotations appear in source +covers_regex: "@covers[[:space:]]+.*" + +# How AC IDs appear inside test names (e.g. test_AC_SYNC_04_...) +test_ac_regex: "AC_[A-Z][A-Z0-9]{1,5}_[0-9]{2,}[a-z]?" diff --git a/.specify/extensions/clewseau-gate/extension.yml b/.specify/extensions/clewseau-gate/extension.yml new file mode 100644 index 0000000..8a2a400 --- /dev/null +++ b/.specify/extensions/clewseau-gate/extension.yml @@ -0,0 +1,43 @@ +schema_version: "1.0" + +extension: + id: "clewseau-gate" + name: "Clewseau Gate" + version: "0.2.0" + description: "Gate 2 — refuse silent gaps and emit a clew (`clew.json`)." + category: "visibility" + effect: "read-write" + author: "Rik Dryfoos / Dryfoos Consulting" + license: "MIT" + homepage: "https://github.com/rdryfoos/clewseau" + repository: "https://github.com/rdryfoos/clewseau" + +requires: + speckit_version: ">=0.14.0" + tools: [] + +provides: + commands: + - name: "speckit.clewseau-gate.check" + file: "commands/speckit.clewseau-gate.check.md" + description: "Run Clewseau Gate 2 (silent gaps fail; write clew.json)" + + config: + - name: "clewseau-gate-config.yml" + template: "config-template.yml" + description: "Registry path, source/test globs, and ID grammar" + required: true + +hooks: + after_implement: + command: "speckit.clewseau-gate.check" + optional: true + prompt: "Run Clewseau Gate 2 traceability check?" + description: "Refuse silent gaps after implementation; refresh clew.json" + +tags: + - "traceability" + - "gate" + - "ci" + - "governance" + - "sdd" diff --git a/.specify/extensions/clewseau-gate/scripts/check-traceability.sh b/.specify/extensions/clewseau-gate/scripts/check-traceability.sh new file mode 100755 index 0000000..1b4c950 --- /dev/null +++ b/.specify/extensions/clewseau-gate/scripts/check-traceability.sh @@ -0,0 +1,458 @@ +#!/usr/bin/env bash +# Clewseau Gate 2 — portable traceability check + clew.json matrix emitter. +# Exact-set registry ≡ specs ≡ tasks. Silent AC gaps and untraced scope fail. +# Tracked debt is allowed. US/FR/NFR without own carrier are backlog (planning altitude) — not silent-gap candidates. +# Anointed backlog: a registry ID whose only carrier is an open Traces TODO is backlog, not drift — minting into +# backlog is deliberate (the TODO proves intent); unclaimed IDs with no TODO still fail exact-set. +# Always writes clew.json (even on failure) so the clew reflects GAPs + gate. +set -euo pipefail + +export LC_ALL=C + +EXT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PROJECT_ROOT="${CLEWSEAU_PROJECT_ROOT:-}" +if [[ -z "$PROJECT_ROOT" ]]; then + # Spec Kit install: /.specify/extensions// → three levels up. + # Clewseau repo layout: /extensions// → two levels up. + parent_dir="$(basename "$(dirname "$EXT_DIR")")" + grandparent_dir="$(basename "$(dirname "$(dirname "$EXT_DIR")")")" + if [[ "$parent_dir" == "extensions" && "$grandparent_dir" == ".specify" ]]; then + PROJECT_ROOT="$(cd "$EXT_DIR/../../.." && pwd)" + else + PROJECT_ROOT="$(cd "$EXT_DIR/../.." && pwd)" + fi +fi +cd "$PROJECT_ROOT" + +CONFIG="${CLEWSEAU_CONFIG:-$EXT_DIR/clewseau-gate-config.yml}" +if [[ ! -f "$CONFIG" ]]; then + if [[ -f "$EXT_DIR/config-template.yml" ]]; then + CONFIG="$EXT_DIR/config-template.yml" + echo "WARN: using config-template.yml; copy to clewseau-gate-config.yml for real projects" >&2 + else + echo "FAIL: no clewseau-gate-config.yml (looked in $EXT_DIR)" >&2 + exit 2 + fi +fi + +yaml_scalar() { + local key="$1" + awk -v k="$key" ' + $0 ~ "^"k":[[:space:]]*" { + sub("^[^:]+:[[:space:]]*", "") + gsub(/^"/, ""); gsub(/"$/, "") + print + exit + } + ' "$CONFIG" +} + +yaml_list() { + local key="$1" + awk -v k="$key" ' + $0 ~ "^"k":[[:space:]]*$" { inlist=1; next } + inlist && /^[^[:space:]-]/ { exit } + inlist && /^[[:space:]]*-[[:space:]]*/ { + sub(/^[[:space:]]*-[[:space:]]*/, "") + gsub(/^"/, ""); gsub(/"$/, "") + print + } + ' "$CONFIG" +} + +REGISTRY="$(yaml_scalar registry)" +SPECS_GLOB="$(yaml_scalar specs)" +TASKS_GLOB="$(yaml_scalar tasks)" +ID_RE="$(yaml_scalar id_regex)" +COVERS_RE="$(yaml_scalar covers_regex)" +TEST_AC_RE="$(yaml_scalar test_ac_regex)" +CLEW_OUT="$(yaml_scalar clew_path)" +TARGET_NAME="$(yaml_scalar target_name)" + +[[ -n "$REGISTRY" ]] || { echo "FAIL: config missing registry" >&2; exit 2; } +[[ -n "$ID_RE" ]] || ID_RE='(FR|NFR|AC|US)-[A-Z][A-Z0-9]{1,5}-[0-9]{2,}[a-z]?' +[[ -n "$COVERS_RE" ]] || COVERS_RE='@covers[[:space:]]+.*' +[[ -n "$TEST_AC_RE" ]] || TEST_AC_RE='AC_[A-Z][A-Z0-9]{1,5}_[0-9]{2,}[a-z]?' +[[ -n "$SPECS_GLOB" ]] || SPECS_GLOB='specs/**/spec.md' +[[ -n "$TASKS_GLOB" ]] || TASKS_GLOB='specs/**/tasks.md' +[[ -n "$CLEW_OUT" ]] || CLEW_OUT='clew.json' +[[ -n "$TARGET_NAME" ]] || TARGET_NAME="$(basename "$PROJECT_ROOT")" + +fail=0 +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +: > "$tmp/failures.jsonl" + +# kind | id-or-empty | detail — collected for clew.json gate.failures +record_fail() { + local kind="$1" + local id="${2:-}" + local detail="$3" + echo "FAIL: $detail" >&2 + fail=1 + python3 -c ' +import json,sys +kind, id_, detail = sys.argv[1], sys.argv[2], sys.argv[3] +row = {"kind": kind, "detail": detail} +if id_: + row["id"] = id_ +print(json.dumps(row, ensure_ascii=False)) +' "$kind" "$id" "$detail" >> "$tmp/failures.jsonl" +} + +if [[ ! -f "$REGISTRY" ]]; then + record_fail "registry-missing" "" "registry not found: $REGISTRY" + # Still try to emit an empty-ish clew below if possible; exit after emit. +fi + +if [[ -f "$REGISTRY" ]]; then + grep -Eoh "$ID_RE" "$REGISTRY" | sort -u > "$tmp/registry.txt" +else + : > "$tmp/registry.txt" +fi + +expand_glob() { + local pattern="$1" + local dir base + if [[ "$pattern" == *\*\*/* ]]; then + dir="${pattern%%/\*\*/*}" + base="${pattern##*/}" + [[ -d "$dir" ]] || return 0 + find "$dir" -type f -name "$base" 2>/dev/null + elif [[ "$pattern" == */* ]]; then + dir="$(dirname "$pattern")" + base="$(basename "$pattern")" + if [[ "$base" == "**" ]]; then + [[ -d "$dir" ]] || return 0 + find "$dir" -type f 2>/dev/null + return 0 + fi + [[ -d "$dir" ]] || return 0 + find "$dir" -type f -name "$base" 2>/dev/null + else + find . -type f -name "$pattern" 2>/dev/null + fi +} + +: > "$tmp/spec.txt" +while IFS= read -r f; do + [[ -f "$f" ]] || continue + grep -Eoh "$ID_RE" "$f" | sort -u >> "$tmp/spec.txt" || true +done < <(expand_glob "$SPECS_GLOB") +sort -u "$tmp/spec.txt" -o "$tmp/spec.txt" + +: > "$tmp/tasks.txt" +: > "$tmp/pending.txt" +: > "$tmp/pending_hits.txt" +while IFS= read -r f; do + [[ -f "$f" ]] || continue + grep -Eoh "$ID_RE" "$f" | sort -u >> "$tmp/tasks.txt" || true + # Open checkbox tasks that name registry IDs (usually via Traces:) — debt carriers. + grep -nE '^- \[ \]' "$f" 2>/dev/null | while IFS= read -r line; do + lineno="${line%%:*}" + rest="${line#*:}" + excerpt="$(printf '%s' "$rest" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | cut -c1-200)" + while IFS= read -r id; do + [[ -n "$id" ]] || continue + printf '%s|%s|%s|%s\n' "$f" "$lineno" "$id" "$excerpt" + done < <(grep -Eo "$ID_RE" <<<"$rest" || true) + done >> "$tmp/pending_hits.txt" || true +done < <(expand_glob "$TASKS_GLOB") +sort -u "$tmp/tasks.txt" -o "$tmp/tasks.txt" +cut -d'|' -f3 "$tmp/pending_hits.txt" 2>/dev/null | sort -u > "$tmp/pending.txt" || : > "$tmp/pending.txt" + + +# covers: path|line|id|excerpt +: > "$tmp/covers_hits.txt" +while IFS= read -r g; do + [[ -z "$g" ]] && continue + while IFS= read -r f; do + [[ -f "$f" ]] || continue + grep -nE "$COVERS_RE" "$f" 2>/dev/null | while IFS= read -r line; do + lineno="${line%%:*}" + rest="${line#*:}" + excerpt="$(printf '%s' "$rest" | tr '\t' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | cut -c1-160)" + # One hit per ID — @covers may list several (e.g. FR-HOME-04, AC-HOME-15). + while IFS= read -r id; do + [[ -n "$id" ]] || continue + printf '%s|%s|%s|%s\n' "$f" "$lineno" "$id" "$excerpt" + done < <(grep -Eo "$ID_RE" <<<"$rest" || true) + done + done < <(expand_glob "$g") +done < <(yaml_list src_globs) >> "$tmp/covers_hits.txt" || true + +cut -d'|' -f3 "$tmp/covers_hits.txt" 2>/dev/null | sort -u > "$tmp/covers.txt" || true + +# proofs: path|line|id|name +: > "$tmp/proof_hits.txt" +while IFS= read -r g; do + [[ -z "$g" ]] && continue + while IFS= read -r f; do + [[ -f "$f" ]] || continue + grep -nE "$TEST_AC_RE" "$f" 2>/dev/null | while IFS= read -r line; do + lineno="${line%%:*}" + rest="${line#*:}" + raw="$(grep -Eo "$TEST_AC_RE" <<<"$rest" | head -1 || true)" + [[ -n "$raw" ]] || continue + id="$(printf '%s' "$raw" | tr '_' '-')" + name="$(grep -Eo 'test_[A-Za-z0-9_]+|func test_[A-Za-z0-9_]+' <<<"$rest" | head -1 | sed 's/^func //' || true)" + [[ -n "$name" ]] || name="$raw" + printf '%s|%s|%s|%s\n' "$f" "$lineno" "$id" "$name" + done + done < <(expand_glob "$g") +done < <(yaml_list test_globs) >> "$tmp/proof_hits.txt" || true + +cut -d'|' -f3 "$tmp/proof_hits.txt" 2>/dev/null | sort -u > "$tmp/test_acs.txt" || true + +# 1) Exact-set drift: registry ≡ specs, registry ≡ tasks (HomesFlow Gate 2 parity). +# Specs/tasks may not invent IDs; registry IDs may not sit unclaimed in either +# artifact — except anointed backlog: a registry ID whose only carrier is an +# open Traces TODO is backlog (deliberate, visible), not drift. +while IFS= read -r id; do + [[ -z "$id" ]] && continue + record_fail "spec-orphan" "$id" "spec references ID not in registry: $id" +done < <(comm -13 "$tmp/registry.txt" "$tmp/spec.txt") + +while IFS= read -r id; do + [[ -z "$id" ]] && continue + # Anointed backlog: an open Traces TODO carries the claim until a spec picks + # the ID up. Unclaimed IDs with no TODO still fail (drift / fat-finger). + if grep -qx "$id" "$tmp/pending.txt"; then + continue + fi + record_fail "spec-unclaimed" "$id" "registry ID missing from specs: $id" +done < <(comm -23 "$tmp/registry.txt" "$tmp/spec.txt") + +while IFS= read -r id; do + [[ -z "$id" ]] && continue + record_fail "task-orphan" "$id" "tasks reference ID not in registry: $id" +done < <(comm -13 "$tmp/registry.txt" "$tmp/tasks.txt") + +while IFS= read -r id; do + [[ -z "$id" ]] && continue + record_fail "task-unclaimed" "$id" "registry ID missing from tasks: $id" +done < <(comm -23 "$tmp/registry.txt" "$tmp/tasks.txt") + +# 2) Every task with a checkbox should have Traces +while IFS= read -r f; do + [[ -f "$f" ]] || continue + while IFS= read -r line; do + if [[ "$line" =~ ^-\ \[[x\ ]\]\ T ]]; then + if ! grep -Eq '\*\*Traces\*\*:' <<<"$line"; then + record_fail "missing-traces" "" "task missing Traces field: ${line:0:80}" + fi + fi + done < "$f" +done < <(expand_glob "$TASKS_GLOB") + +# 3) Untraced scope +while IFS= read -r id; do + [[ -z "$id" ]] && continue + grep -qx "$id" "$tmp/registry.txt" || record_fail "orphan-covers" "$id" "untraced scope (@covers): $id not in registry" +done < "$tmp/covers.txt" + +while IFS= read -r id; do + [[ -z "$id" ]] && continue + grep -qx "$id" "$tmp/registry.txt" || record_fail "orphan-test" "$id" "untraced scope (test name): $id not in registry" +done < "$tmp/test_acs.txt" + +# 4) Silent gaps — ACs only (coverage altitude: AC is the atomic proof unit) +while IFS= read -r id; do + [[ -z "$id" ]] && continue + [[ "${id%%-*}" == "AC" ]] || continue + if grep -qx "$id" "$tmp/test_acs.txt"; then + continue + fi + if grep -qx "$id" "$tmp/pending.txt"; then + continue + fi + record_fail "silent-gap" "$id" "silent gap: $id has no test and no open tracked-debt task" +done < "$tmp/registry.txt" + +# --- Emit clew.json (always; clew should show GAPs even when Gate fails) --- +export CLEW_OUT REGISTRY TARGET_NAME PROJECT_ROOT +export CLEW_TMP="$tmp" +export CLEW_FAIL="$fail" +python3 - <<'PY' +import json, os, re, datetime +from pathlib import Path + +tmp = Path(os.environ["CLEW_TMP"]) +registry_path = Path(os.environ["REGISTRY"]) +out_path = Path(os.environ["CLEW_OUT"]) +target = os.environ.get("TARGET_NAME") or "project" +repo = os.environ.get("PROJECT_ROOT") or str(Path.cwd()) +gate_failed = os.environ.get("CLEW_FAIL", "0") != "0" + +ids = [ln.strip() for ln in (tmp / "registry.txt").read_text().splitlines() if ln.strip()] +pending = {ln.strip() for ln in (tmp / "pending.txt").read_text().splitlines() if ln.strip()} +tested = {ln.strip() for ln in (tmp / "test_acs.txt").read_text().splitlines() if ln.strip()} +covered = {ln.strip() for ln in (tmp / "covers.txt").read_text().splitlines() if ln.strip()} +spec_ids = {ln.strip() for ln in (tmp / "spec.txt").read_text().splitlines() if ln.strip()} + +failures = [] +fail_path = tmp / "failures.jsonl" +if fail_path.exists(): + for ln in fail_path.read_text().splitlines(): + if ln.strip(): + failures.append(json.loads(ln)) + +statements = {} +registry_refs = {} +if registry_path.is_file(): + reg_text = registry_path.read_text(encoding="utf-8", errors="replace").splitlines() +else: + reg_text = [] +try: + registry_rel = os.path.relpath(os.path.realpath(registry_path), os.path.realpath(repo)) +except ValueError: + registry_rel = str(registry_path) +for id_ in ids: + statements[id_] = id_ + for n, line in enumerate(reg_text, start=1): + if id_ in line: + s = re.sub(r"^[\s#\-*\[\]xX]+", "", line).strip() + statements[id_] = s or id_ + registry_refs[id_] = {"path": registry_rel, "line": n} + break + +impl_by = {i: [] for i in ids} +covers_file = tmp / "covers_hits.txt" +if covers_file.exists(): + for ln in covers_file.read_text().splitlines(): + if not ln.strip(): + continue + parts = ln.split("|", 3) + if len(parts) < 3: + continue + path, line, id_ = parts[0], parts[1], parts[2] + excerpt = parts[3] if len(parts) > 3 else "" + if id_ not in impl_by: + continue + try: + line_n = int(line) + except ValueError: + line_n = 0 + impl_by[id_].append({"path": path, "line": line_n, "excerpt": excerpt}) + +proof_by = {i: [] for i in ids} +proofs_file = tmp / "proof_hits.txt" +if proofs_file.exists(): + for ln in proofs_file.read_text().splitlines(): + if not ln.strip(): + continue + parts = ln.split("|", 3) + if len(parts) < 3: + continue + path, line, id_ = parts[0], parts[1], parts[2] + name = parts[3] if len(parts) > 3 else id_ + if id_ not in proof_by: + continue + try: + line_n = int(line) + except ValueError: + line_n = 0 + proof_by[id_].append({"name": name, "path": path, "line": line_n}) + +debt_by = {i: [] for i in ids} +pending_hits = tmp / "pending_hits.txt" +seen_debt = set() +if pending_hits.exists(): + for ln in pending_hits.read_text().splitlines(): + if not ln.strip(): + continue + parts = ln.split("|", 3) + if len(parts) < 3: + continue + path, line, id_ = parts[0], parts[1], parts[2] + excerpt = parts[3] if len(parts) > 3 else "" + if id_ not in debt_by: + continue + key = (path, line, id_) + if key in seen_debt: + continue + seen_debt.add(key) + try: + line_n = int(line) + except ValueError: + line_n = 0 + debt_by[id_].append({"path": path, "line": line_n, "excerpt": excerpt}) + +def status_for(id_: str) -> str: + typ = id_.split("-", 1)[0] + # Open TODO splits by whether work started: spec/impl presence means debt + # (excused incompleteness); registry entry + TODO and nothing else means + # anointed backlog (minted on purpose, not yet picked up). + started = id_ in spec_ids or id_ in covered + if typ == "AC": + if id_ in tested: + return "verified" + if id_ in pending: + return "tracked-debt" if started else "backlog" + return "GAP" + # US/FR/NFR: planning altitude — backlog when no own carrier; not silent-gap / GAP. + if id_ in covered or id_ in tested: + return "verified" + if id_ in pending: + return "tracked-debt" if started else "backlog" + return "backlog" + +rows = [] +status_counts = {"verified": 0, "tracked-debt": 0, "GAP": 0, "backlog": 0} +ac_count = 0 +covered_count = 0 +for id_ in ids: + typ = id_.split("-", 1)[0] + st = status_for(id_) + status_counts[st] += 1 + if typ == "AC": + ac_count += 1 + if st == "verified": + covered_count += 1 + rows.append({ + "id": id_, + "type": typ, + "statement": statements.get(id_, id_), + "registry": registry_refs.get(id_), + "status": st, + "implementations": impl_by.get(id_, []), + "proofs": proof_by.get(id_, []), + "debtTasks": debt_by.get(id_, []), + "attestedBy": None, + }) + +doc = { + "schemaVersion": 3, + "format": "clew", + "emitter": "clewseau-gate", + "targetName": target, + "repoPath": repo, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + "gate": { + "ok": not gate_failed and len(failures) == 0, + "failures": failures, + }, + "totals": { + "registryIdCount": len(ids), + "acCount": ac_count, + "coveredCount": covered_count, + }, + "statusCounts": status_counts, + "rows": rows, +} + +# Prefer structured failures if bash recorded any; else respect CLEW_FAIL. +if failures: + doc["gate"]["ok"] = False + +out_path.parent.mkdir(parents=True, exist_ok=True) +out_path.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") +print(f"Wrote {out_path} ({len(rows)} rows) gate.ok={doc['gate']['ok']}", flush=True) +PY + +if [[ "$fail" -ne 0 ]]; then + echo "Clewseau Gate 2: FAILED" >&2 + exit 1 +fi + +echo "Clewseau Gate 2: OK ($(wc -l < "$tmp/registry.txt" | tr -d ' ') registry IDs)" diff --git a/.specify/feature.json b/.specify/feature.json index 393529c..6113c0e 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1,3 @@ -{"feature_directory":"specs/001-mvp"} +{ + "feature_directory": "specs/002-home-name-normalize" +} diff --git a/.specify/init-options.json b/.specify/init-options.json index 2a307ad..e836523 100644 --- a/.specify/init-options.json +++ b/.specify/init-options.json @@ -5,5 +5,5 @@ "here": true, "integration": "claude", "script": "sh", - "speckit_version": "0.11.9.dev0" -} \ No newline at end of file + "speckit_version": "0.15.3.dev0" +} diff --git a/.specify/integration.json b/.specify/integration.json index 4fffd24..2556803 100644 --- a/.specify/integration.json +++ b/.specify/integration.json @@ -1,5 +1,5 @@ { - "version": "0.11.9.dev0", + "version": "0.15.3.dev0", "integration_state_schema": 1, "installed_integrations": [ "claude" diff --git a/.specify/integrations/claude.manifest.json b/.specify/integrations/claude.manifest.json index ccc09f5..593ce62 100644 --- a/.specify/integrations/claude.manifest.json +++ b/.specify/integrations/claude.manifest.json @@ -1,17 +1,17 @@ { "integration": "claude", - "version": "0.11.9.dev0", - "installed_at": "2026-06-26T14:27:06.733387+00:00", + "version": "0.15.3.dev0", + "installed_at": "2026-08-06T20:51:33.845561+00:00", "files": { - ".claude/skills/speckit-analyze/SKILL.md": "73ec1460968620f0e3e9aa3568d57cc789e2414cf5cac2abbf5fee0d6511b565", - ".claude/skills/speckit-clarify/SKILL.md": "c1c2098756ca407530cca11c5b608f517d769962215ddafa013951b81e3e19c5", - ".claude/skills/speckit-constitution/SKILL.md": "ee3972318415a05559c6bf281dcbd2e8deda944e595d64ab5474abeacf558697", - ".claude/skills/speckit-implement/SKILL.md": "823049e49aa983fe398d4bccf6c686ab6afe8f2cd3856e0380c3ef797d78d56d", - ".claude/skills/speckit-converge/SKILL.md": "04226b8443797337624983111546d5e5a48d9993a176c4e6d72a4099a0af50d4", - ".claude/skills/speckit-plan/SKILL.md": "53733c8a4f4fd01685759bb1c68e94c73da4ce90d549139e79e419dec6471510", - ".claude/skills/speckit-checklist/SKILL.md": "946c6bc808891436972a11a423f89f0fbd272a79809bb8fd1d29f481ebe02613", - ".claude/skills/speckit-specify/SKILL.md": "9324dd55d12d420cd581031419fa37eb94ef75ae0bdd53391dd4414bd9d45e02", - ".claude/skills/speckit-tasks/SKILL.md": "cb29fb8247a30aac751be83de88d0399221692589dd26327552ae6f193816fda", - ".claude/skills/speckit-taskstoissues/SKILL.md": "dfe23aaca349cd76e98505dafa9aae1ef4616a0c35a5c79122b9bd881e16b62f" + ".claude/skills/speckit-analyze/SKILL.md": "e1f8323285ed23a9ec6b74a6436d7ebd23a3511778d5cc181bd27afa2b545427", + ".claude/skills/speckit-clarify/SKILL.md": "f4b3f2c95087ac2343c0b67faff67f7223d34213ca1816aa25908db5b9aff0ac", + ".claude/skills/speckit-constitution/SKILL.md": "2605d00afc53af300ce2efd630bc1103b5fa6acefd3e33ead63fd27b3f63abee", + ".claude/skills/speckit-implement/SKILL.md": "624632825da8f2be64eadb50292506d4f067c785f5a2f866b62e908e582c6b50", + ".claude/skills/speckit-converge/SKILL.md": "65ea8b863835750931b522a9935391da887c4d188f1410113d16efe6396c2214", + ".claude/skills/speckit-plan/SKILL.md": "2fe3f96886e96284965c8586d14df5d226c1e796b3eb39d110c9d8231abe6417", + ".claude/skills/speckit-checklist/SKILL.md": "091388fe7283456088c72e6674bc751b4e745a7fb656a8d6392b4fb3435e8538", + ".claude/skills/speckit-specify/SKILL.md": "42fe016b9183bb8fa7ce7c65e04ea8d382f7f2abfc94849aeead999247675886", + ".claude/skills/speckit-tasks/SKILL.md": "8323958595e60c9111d82d875e6d1e4c86f272481ae010f67876ce525603015f", + ".claude/skills/speckit-taskstoissues/SKILL.md": "82743575ccbd0831e7f7e187785e9ff650fd1ca81feca81520a29d1fff0bea44" } } diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json index cd4dd70..215aca7 100644 --- a/.specify/integrations/speckit.manifest.json +++ b/.specify/integrations/speckit.manifest.json @@ -1,17 +1,17 @@ { "integration": "speckit", - "version": "0.11.9.dev0", - "installed_at": "2026-06-26T14:27:06.744456+00:00", + "version": "0.15.3.dev0", + "installed_at": "2026-08-06T20:51:33.855852+00:00", "files": { - ".specify/scripts/bash/common.sh": "af8a16f87b4f9084759c42ff9abf35c0b2a2025dffe58c298758ff86de2923b2", - ".specify/scripts/bash/setup-plan.sh": "4eb12c5b00f5c66a7d01b56c90898d320dcef4425d9b96652d57156c84948eda", + ".specify/scripts/bash/common.sh": "6ff86bf39f6b4684b0f80927dc7a1dadec26b4671988a3fe4d6c2523cbd3aa22", + ".specify/scripts/bash/setup-plan.sh": "4469b22960f43c07c33dca00de6dedb252145e9a9ce8fbb0e63be82e02b082ab", ".specify/scripts/bash/setup-tasks.sh": "cf21ba2212b4dd5b435c5ea8527500cfd27768b86c0bbc7ebc3207759f118d27", - ".specify/scripts/bash/check-prerequisites.sh": "afce0aa8db177320d83aa0b8e3619c06b865fd810781894e4a7a3f81664941ce", - ".specify/scripts/bash/create-new-feature.sh": "9ba116b64f0328eb69bc1a195d209074ea38823a73a554160d69df34a74daa65", + ".specify/scripts/bash/check-prerequisites.sh": "a7d8a14ecf87332b600cd966b5d0e7cb9d594abce7e4d1ee4372b2b5b3efff06", + ".specify/scripts/bash/create-new-feature.sh": "ad09a94a2c1107e25e5386a834da1d7a31f9abb06ab8bfd323a7b84038221e39", ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", - ".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c", + ".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536", ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", - ".specify/templates/plan-template.md": "cc7f7979cf8d8836ec26492785affd80791d3422a2b745062ec695be8c985ef7" + ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4" } } diff --git a/.specify/memory/.constitution-template.json b/.specify/memory/.constitution-template.json new file mode 100644 index 0000000..7ff59f4 --- /dev/null +++ b/.specify/memory/.constitution-template.json @@ -0,0 +1,4 @@ +{ + "sha256": "ce246ad01b25a37decf728ba20c5c23f770ba4cfc981a55224f5ce1e6c9f52dd", + "source": "clewseau v0.1.0" +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 897ae2b..89f6e8f 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,63 +1,80 @@ -# HomesFlow Constitution - -Non-negotiable laws for this codebase. Product requirements live in `HomesFlow.prd.md`. Traceability mechanics live in `traceability.md`. +# [PROJECT_NAME] Constitution + ## Core Principles -### I. Spec-Driven Development (NON-NEGOTIABLE) - -No production code without an approved feature spec, plan, and tasks under `specs/`. Run `/speckit.analyze` and resolve all violations before `/speckit.implement`. - -### II. Native iOS First - -HomesFlow ships as a native iOS app (Swift / SwiftUI) for iPhone and iPad. No Android, web, or desktop in the initial release (per PRD non-goals). +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + -### III. Offline-Capable Sync (NON-NEGOTIABLE) +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + -Local caching and offline sync are core from day one. Conflict resolution follows the PRD's data-type-aware model (AC-SYNC-01, AC-SYNC-05…07): conflicts resolve automatically by most-recent timestamp, except that terminal step statuses (Complete / N/A) are never silently regressed; the losing user is always notified with guidance to re-apply. Structural actions require connectivity. The PRD's Offline sync AC section is authoritative for the per-data-type rules. +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + -### IV. Role-Based Access +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + -Every data operation respects Owner / Manager / Guest roles per home. UI and API must fail closed — insufficient permission blocks the action with a clear message. +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + -### V. End-to-End Traceability (NON-NEGOTIABLE) +## [SECTION_2_NAME] + -Every FR, NFR, and AC carries a durable ID assigned at the PRD level (`--`). Each AC is atomic and maps to at least one automated test. Every task in `tasks.md` declares `Traces: `. `/speckit.analyze` MUST report zero traceability violations before implementation. +[SECTION_2_CONTENT] + -Full mechanics: `traceability.md`. +## [SECTION_3_NAME] + -### VI. Accessible by Design +[SECTION_3_CONTENT] + -UI MUST respect iOS accessibility settings — especially Dynamic Type, VoiceOver, Reduce Motion, and sufficient contrast. Layouts MUST remain usable at all supported content size categories (**NFR-A11Y-01**). Accessibility is MVP scope, not post-launch polish. - -## Technology Constraints +## Governance + -- **Client**: Swift / SwiftUI (iOS) -- **Backend**: Supabase (PostgreSQL, auth, real-time sync) — see `specs/001-mvp/dev-notes.md` for rationale -- **Process**: Spec Kit (`.specify/`, `specs/`) -- **Testing**: XCTest / XCUITest when source exists +[GOVERNANCE_RULES] + -## Hierarchy of Truth +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + -When documents conflict, higher layers win: -1. This constitution (`.specify/memory/constitution.md`) -2. `glossary.md` -3. `HomesFlow.prd.md` (product requirements and AC registry) -4. `specs//spec.md` (feature slice derived from PRD) -5. `specs//plan.md` and `tasks.md` -6. Source code -**Scope vs process.** Product scope and acceptance criteria are authoritative only in `HomesFlow.prd.md`. This constitution governs *how* we build (process, architecture, traceability). If the PRD is silent on a product behavior, do not invent scope here — amend the PRD. If documents conflict on *engineering law*, this constitution wins; on *what the product must do*, the PRD wins. + -External planning aids (e.g. StoriesOnBoard story maps) may inform humans when drafting or revising the PRD. They are not part of this hierarchy and are not Gate-2-enforced. +### Article: End-to-End Traceability (NON-NEGOTIABLE) -## Governance +Every functional requirement, non-functional requirement, and acceptance criterion carries a durable unique ID of the form `--` (e.g. `FR-LOG-01`, `AC-OFFL-03`). IDs are assigned once at the PRD level and are never reused or renumbered; retired IDs are tombstoned, not recycled. -Amendments require a version bump and brief rationale. Traceability mechanics live in `traceability.md`; engineering and toolchain notes in `specs/001-mvp/dev-notes.md`. Neither overrides this file or the PRD. +1. Each acceptance criterion is **atomic** — one independently testable assertion — and maps to at least one automated test *or* an explicitly tracked debt entry. Silent-gap refusal is at **AC altitude**; US/FR/NFR IDs are planning labels (clew status `backlog`), not silent-gap candidates. +2. Every task in `tasks.md` MUST declare the ID(s) it implements via a `Traces:` field. +3. Every verifying test MUST encode the AC ID it protects. Every requirement-bearing source module MUST carry a coverage annotation naming the ID. +4. Coverage is **bidirectional** and machine-checked: no silent AC gaps, no untraced scope, and exact-set registry ≡ specs ≡ tasks. CI fails the build on any of these. +5. `/speckit.analyze` MUST report zero Clewseau traceability violations before `/speckit.implement` runs. -**Version**: 1.2.1 | **Ratified**: 2026-06-28 | **Last Amended**: 2026-07-17 +### Article: Clewseau vocabulary -**1.2.1 rationale**: Clarified Hierarchy of Truth so agents do not blur process law (constitution) with product scope (PRD). Story maps and similar tools stay outside the machine-checked chain. +Use these terms; do not invent synonyms (especially not “dossier”). -**1.2.0 rationale**: Principle III previously hardcoded blanket timestamp-wins. Amended to defer to the PRD's data-type-aware conflict model (story-map decision 2026-07-03): automatic resolution with loser notification, terminal-status protection, connectivity-gated structural actions. Keeps the constitution stable as per-data-type rules evolve in the PRD. +| Term | Meaning | +|------|---------| +| **clew** | The Gate-emitted traceability artifact (`format: "clew"`). Default filename `clew.json`. | +| **clew.json** | Usual on-disk path for a clew (configurable via Gate `clew_path`). | +| **Clewseau** | Spec Kit overlay: durable IDs, Gate 2, clew emission. Not Thorsten Schlathölter’s open-source `clew` tool. | +| **clewloupe** | Viewer that consumes a clew only — no target re-scan. | +| **verified** | Named carrier exists (AC proof and/or `@covers` / proof for US/FR/NFR). | +| **tracked-debt** | Incomplete, but declared on an open task with `Traces:`. | +| **GAP** | Silent AC gap — neither proof nor open debt; Gate refuses; thread frays. | +| **backlog** | US/FR/NFR with no own carrier — planning altitude, not a silent gap. | +| **Gate 2** | Deterministic Clewseau check + clew emit (`speckit.clewseau-gate.check`). | diff --git a/.specify/presets/.registry b/.specify/presets/.registry new file mode 100644 index 0000000..205e799 --- /dev/null +++ b/.specify/presets/.registry @@ -0,0 +1,15 @@ +{ + "schema_version": "1.0", + "presets": { + "clewseau": { + "version": "0.1.0", + "source": "local", + "manifest_hash": "sha256:b00105f1e0ebc2caa5ece6fd23f1d6431053cf8ce76a531a47b5dcd99b10c82b", + "enabled": true, + "priority": 10, + "registered_commands": {}, + "registered_skills": {}, + "installed_at": "2026-08-06T20:51:47.715671+00:00" + } + } +} \ No newline at end of file diff --git a/.specify/presets/clewseau/GLOSSARY.md b/.specify/presets/clewseau/GLOSSARY.md new file mode 100644 index 0000000..74a5ee1 --- /dev/null +++ b/.specify/presets/clewseau/GLOSSARY.md @@ -0,0 +1,17 @@ +# Clewseau vocabulary + +Copy into the project glossary (e.g. `glossary.md`) when the repo keeps one. The same terms are appended to the constitution via the Clewseau preset. + +| Term | Meaning | +|------|---------| +| **clew** | The Gate-emitted traceability artifact (`format: "clew"`). Default filename `clew.json`. | +| **clew.json** | Usual on-disk path for a clew (configurable via Gate `clew_path`). | +| **Clewseau** | Spec Kit overlay: durable IDs, Gate 2, clew emission. Not Thorsten Schlathölter’s open-source `clew` tool. | +| **clewloupe** | Viewer that consumes a clew only — no target re-scan. | +| **verified** | Named carrier exists (AC proof and/or `@covers` / proof for US/FR/NFR). | +| **tracked-debt** | Incomplete, but declared on an open task with `Traces:`. | +| **GAP** | Silent AC gap — neither proof nor open debt; Gate refuses; thread frays. | +| **backlog** | US/FR/NFR with no own carrier — planning altitude, not a silent gap. | +| **Gate 2** | Deterministic Clewseau check + clew emit (`speckit.clewseau-gate.check`). | + +Do not use **dossier** for this artifact — in Clewseau it is a **clew**. diff --git a/.specify/presets/clewseau/README.md b/.specify/presets/clewseau/README.md new file mode 100644 index 0000000..ca4ded8 --- /dev/null +++ b/.specify/presets/clewseau/README.md @@ -0,0 +1,13 @@ +# Clewseau preset + +Appends Clewseau's durable-ID rules and vocabulary onto Spec Kit core templates via `append` strategy. Does not replace Spec Kit's workflow. + +Install: + +```bash +specify preset add --dev /path/to/clewseau/presets/clewseau +``` + +Vocabulary (clew, statuses, Gate 2) lands in the constitution template. For projects that keep a separate glossary, also merge [`GLOSSARY.md`](./GLOSSARY.md). + +See the repo root [`PROMOTION-CONTRACT.md`](../../PROMOTION-CONTRACT.md). diff --git a/.specify/presets/clewseau/preset.yml b/.specify/presets/clewseau/preset.yml new file mode 100644 index 0000000..6916589 --- /dev/null +++ b/.specify/presets/clewseau/preset.yml @@ -0,0 +1,40 @@ +schema_version: "1.0" + +preset: + id: "clewseau" + name: "Clewseau" + version: "0.1.0" + description: "Appends durable-ID, Traces, and Clewseau vocabulary onto Spec Kit spec, tasks, and constitution templates." + author: "Rik Dryfoos / Dryfoos Consulting" + license: "MIT" + homepage: "https://github.com/rdryfoos/clewseau" + repository: "https://github.com/rdryfoos/clewseau" + +requires: + speckit_version: ">=0.14.0" + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Require inherited durable IDs and a risk table traced to FR/AC IDs" + strategy: "append" + + - type: "template" + name: "tasks-template" + file: "templates/tasks-template.md" + description: "Require Traces: on every task" + strategy: "append" + + - type: "template" + name: "constitution-template" + file: "templates/constitution-template.md" + description: "Append End-to-End Traceability article and Clewseau vocabulary" + strategy: "append" + +tags: + - "traceability" + - "durable-ids" + - "governance" + - "sdd" diff --git a/.specify/presets/clewseau/templates/constitution-template.md b/.specify/presets/clewseau/templates/constitution-template.md new file mode 100644 index 0000000..098e20f --- /dev/null +++ b/.specify/presets/clewseau/templates/constitution-template.md @@ -0,0 +1,28 @@ + + + +### Article: End-to-End Traceability (NON-NEGOTIABLE) + +Every functional requirement, non-functional requirement, and acceptance criterion carries a durable unique ID of the form `--` (e.g. `FR-LOG-01`, `AC-OFFL-03`). IDs are assigned once at the PRD level and are never reused or renumbered; retired IDs are tombstoned, not recycled. + +1. Each acceptance criterion is **atomic** — one independently testable assertion — and maps to at least one automated test *or* an explicitly tracked debt entry. Silent-gap refusal is at **AC altitude**; US/FR/NFR IDs are planning labels (clew status `backlog`), not silent-gap candidates. +2. Every task in `tasks.md` MUST declare the ID(s) it implements via a `Traces:` field. +3. Every verifying test MUST encode the AC ID it protects. Every requirement-bearing source module MUST carry a coverage annotation naming the ID. +4. Coverage is **bidirectional** and machine-checked: no silent AC gaps, no untraced scope, and exact-set registry ≡ specs ≡ tasks. CI fails the build on any of these. +5. `/speckit.analyze` MUST report zero Clewseau traceability violations before `/speckit.implement` runs. + +### Article: Clewseau vocabulary + +Use these terms; do not invent synonyms (especially not “dossier”). + +| Term | Meaning | +|------|---------| +| **clew** | The Gate-emitted traceability artifact (`format: "clew"`). Default filename `clew.json`. | +| **clew.json** | Usual on-disk path for a clew (configurable via Gate `clew_path`). | +| **Clewseau** | Spec Kit overlay: durable IDs, Gate 2, clew emission. Not Thorsten Schlathölter’s open-source `clew` tool. | +| **clewloupe** | Viewer that consumes a clew only — no target re-scan. | +| **verified** | Named carrier exists (AC proof and/or `@covers` / proof for US/FR/NFR). | +| **tracked-debt** | Incomplete, but declared on an open task with `Traces:`. | +| **GAP** | Silent AC gap — neither proof nor open debt; Gate refuses; thread frays. | +| **backlog** | US/FR/NFR with no own carrier — planning altitude, not a silent gap. | +| **Gate 2** | Deterministic Clewseau check + clew emit (`speckit.clewseau-gate.check`). | diff --git a/.specify/presets/clewseau/templates/spec-template.md b/.specify/presets/clewseau/templates/spec-template.md new file mode 100644 index 0000000..b81fa00 --- /dev/null +++ b/.specify/presets/clewseau/templates/spec-template.md @@ -0,0 +1,19 @@ + + + + +## Clewseau — durable IDs (required) + +Every requirement and acceptance criterion MUST carry a durable ID per the project Clewseau / traceability standard. + +- IDs are assigned **once at the PRD (registry) level**. This feature spec **inherits** them; do **not** mint new IDs here. +- Grammar: `--` where TYPE is `US` | `FR` | `NFR` | `AC` (example: `AC-SYNC-04`). +- Acceptance criteria are **atomic** — one independently testable assertion each. Split compounds before specify. + +## Risk & failure modes (required) + +List material failures, user impact, and mitigations traced to FR/AC IDs: + +| Failure | User impact | Mitigation / trace | +|---------|-------------|-------------------| +| [e.g. sync conflict] | [user-visible effect] | [e.g. AC-SYNC-01] | diff --git a/.specify/presets/clewseau/templates/tasks-template.md b/.specify/presets/clewseau/templates/tasks-template.md new file mode 100644 index 0000000..a257fb5 --- /dev/null +++ b/.specify/presets/clewseau/templates/tasks-template.md @@ -0,0 +1,11 @@ + + + + +## Clewseau — Traces (required) + +Every task MUST declare the registry ID(s) it implements: + +- Format on each task line: `**Traces**: AC-…, FR-…` (one or more IDs from the PRD registry). +- Do not invent IDs in tasks. If an ID is missing from the registry, stop and fix the PRD first. +- Test tasks that verify an AC SHOULD encode that AC in the test name (e.g. `test_AC_SYNC_04_…`). diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh index 8377d8e..bf75140 100755 --- a/.specify/scripts/bash/check-prerequisites.sh +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -57,13 +57,13 @@ OPTIONS: EXAMPLES: # Check task prerequisites (plan.md required) ./check-prerequisites.sh --json - + # Check implementation prerequisites (plan.md + tasks.md required) ./check-prerequisites.sh --json --require-tasks --include-tasks - + # Get feature paths only (no validation) ./check-prerequisites.sh --paths-only - + EOF exit 0 ;; @@ -78,8 +78,14 @@ done SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -# Get feature paths -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +# Get feature paths. +# In --paths-only mode this is pure resolution, so pass --no-persist to opt out +# of the feature.json write side effect (issue #3025). +if $PATHS_ONLY; then + _paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +else + _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +fi eval "$_paths_output" unset _paths_output @@ -176,13 +182,13 @@ else # Text output echo "FEATURE_DIR:$FEATURE_DIR" echo "AVAILABLE_DOCS:" - + # Show status of each potential document check_file "$RESEARCH" "research.md" check_file "$DATA_MODEL" "data-model.md" check_dir "$CONTRACTS_DIR" "contracts/" check_file "$QUICKSTART" "quickstart.md" - + if $INCLUDE_TASKS; then check_file "$TASKS" "tasks.md" fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh index 70ab89b..dc60f9f 100755 --- a/.specify/scripts/bash/common.sh +++ b/.specify/scripts/bash/common.sh @@ -97,17 +97,26 @@ read_feature_json_feature_directory() { local fj="$repo_root/.specify/feature.json" [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + # Try parsers in order (jq -> python3 -> grep/sed), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App Execution + # Alias stub, which passes `command -v` but fails at runtime (exit 49), so + # an availability-gated `elif` would pick python3, swallow its failure, and + # never reach the grep/sed fallback -- leaving feature.json unreadable even + # though it is valid (issue #3304). local _fd='' if command -v jq >/dev/null 2>&1; then if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then _fd='' fi - elif command -v python3 >/dev/null 2>&1; then + fi + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then # Use Python so pretty-printed/multi-line JSON still parses correctly. if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then _fd='' fi - else + fi + if [[ -z "$_fd" ]]; then # Last-resort single-line grep/sed fallback. The `|| true` guards against # grep returning 1 (no match) aborting under `set -e` / `pipefail`. _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ @@ -152,6 +161,15 @@ _persist_feature_json() { } get_feature_paths() { + # Read-only callers (e.g. check-prerequisites.sh --paths-only) pass + # --no-persist so pure path resolution never writes .specify/feature.json, + # which would dirty the working tree or overwrite a pinned value (issue #3025). + local no_persist=false + if [[ "${1:-}" == "--no-persist" ]]; then + no_persist=true + shift + fi + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in # get_repo_root propagates as a hard error instead of being masked by `local`. local repo_root @@ -168,8 +186,11 @@ get_feature_paths() { feature_dir="$SPECIFY_FEATURE_DIRECTORY" # Normalize relative paths to absolute under repo root [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" - # Persist to feature.json so future sessions without the env var still work - _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" + # Persist to feature.json so future sessions without the env var still + # work — unless the caller opted out for read-only resolution (#3025). + if [[ "$no_persist" != true ]]; then + _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" + fi elif [[ -f "$repo_root/.specify/feature.json" ]]; then local _fd _fd=$(read_feature_json_feature_directory "$repo_root") @@ -186,6 +207,15 @@ get_feature_paths() { return 1 fi + # When no branch context exists (no SPECIFY_FEATURE, feature resolved via + # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature + # directory basename so CURRENT_BRANCH is a usable identifier rather than + # an empty, misleading value (issue #3026). + if [[ -z "$current_branch" ]]; then + local feature_dir_trimmed="${feature_dir%/}" + current_branch="${feature_dir_trimmed##*/}" + fi + # Use printf '%q' to safely quote values, preventing shell injection # via crafted branch names or paths containing special characters printf 'REPO_ROOT=%q\n' "$repo_root" @@ -214,21 +244,29 @@ get_invoke_separator() { local integration_json="$repo_root/.specify/integration.json" local separator="." - local parsed_with_jq=0 + local parsed=0 if [[ -f "$integration_json" ]]; then + # Try parsers in order (jq -> python3 -> awk), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App + # Execution Alias stub, which passes `command -v` but fails at runtime + # (exit 49). An availability-gated branch would pick python3, swallow + # its failure, and — because this function historically had no text + # fallback — silently return "." even for `-`-separator integrations + # (e.g. forge, cline), yielding wrong command hints (issue #3304). if command -v jq >/dev/null 2>&1; then local jq_separator if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then - parsed_with_jq=1 case "$jq_separator" in - "."|"-") separator="$jq_separator" ;; + "."|"-") separator="$jq_separator"; parsed=1 ;; esac fi fi - if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then - if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null + if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then + local py_separator + if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null import json import sys @@ -244,17 +282,64 @@ try: separator = entry["invoke_separator"] print(separator) except Exception: - print(".") + sys.exit(1) PY ); then - case "$separator" in - "."|"-") ;; - *) separator="." ;; + case "$py_separator" in + "."|"-") separator="$py_separator"; parsed=1 ;; esac - else - separator="." fi fi + + if [[ "$parsed" -eq 0 ]]; then + # Last-resort text fallback for environments with neither jq nor a + # working python3 (e.g. stock Windows + Git Bash). Reads the active + # integration key (default_integration, else integration) and its + # invoke_separator from within the integration_settings object. + # Handles both pretty-printed (the written form) and compact JSON. + # Accumulate all lines into one buffer in END rather than using + # gawk-only whole-file slurp (RS="^$"), so this stays portable to + # the BSD awk on macOS. + local awk_separator + awk_separator=$(awk ' + function keyval(d, name, v) { + if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) { + v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v + } + return "" + } + { doc = doc $0 "\n" } + END { + key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration") + sep="." + if (key!="") { + settings=doc + if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) { + settings=substr(doc, RSTART+RLENGTH-1) + } + if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) { + start=RSTART+RLENGTH-1 + depth=0 + obj="" + for (i=start; i<=length(settings); i++) { + c=substr(settings,i,1) + obj=obj c + if (c=="{") depth++ + else if (c=="}") { depth--; if (depth==0) break } + } + if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) { + tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1) + if (s=="." || s=="-") sep=s + } + } + } + print sep + } + ' "$integration_json" 2>/dev/null) + case "$awk_separator" in + "."|"-") separator="$awk_separator" ;; + esac + fi fi _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh index c960976..c1b189d 100755 --- a/.specify/scripts/bash/create-new-feature.sh +++ b/.specify/scripts/bash/create-new-feature.sh @@ -8,6 +8,7 @@ ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" USE_TIMESTAMP=false +NUMBER_EXPLICIT=false ARGS=() i=1 while [ $i -le $# ]; do @@ -48,6 +49,9 @@ while [ $i -le $# ]; do exit 1 fi BRANCH_NUMBER="$next_arg" + if [ -n "$BRANCH_NUMBER" ]; then + NUMBER_EXPLICIT=true + fi ;; --timestamp) USE_TIMESTAMP=true @@ -60,7 +64,7 @@ while [ $i -le $# ]; do echo " --dry-run Compute feature name and paths without creating directories or files" echo " --allow-existing-branch Reuse an existing feature directory if it already exists" echo " --short-name Provide a custom short name (2-4 words) for the feature" - echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --help, -h Show this help message" echo "" @@ -90,11 +94,25 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then exit 1 fi +MAX_FEATURE_NUMBER=9223372036854775807 +MAX_BRANCH_LENGTH=244 + +is_feature_number_in_range() { + local value="$1" + local normalized="${value#"${value%%[!0]*}"}" + [ -n "$normalized" ] || normalized=0 + [ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0 + [ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1 + # Equal-length digit strings must be compared without arithmetic overflow. + # shellcheck disable=SC2071 + [[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]] +} + # Function to get highest number from specs directory get_highest_from_specs() { local specs_dir="$1" local highest=0 - + if [ -d "$specs_dir" ]; then for dir in "$specs_dir"/*; do [ -d "$dir" ] || continue @@ -102,23 +120,66 @@ get_highest_from_specs() { # Match sequential prefixes (>=3 digits), but skip timestamp dirs. if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then number=$(echo "$dirname" | grep -Eo '^[0-9]+') - number=$((10#$number)) - if [ "$number" -gt "$highest" ]; then - highest=$number + if is_feature_number_in_range "$number"; then + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi fi fi done fi - + echo "$highest" } +# Return success when a spec directory owns the given numeric prefix. +spec_prefix_exists() { + local specs_dir="$1" + local feature_num="$2" + + for spec_path in "$specs_dir/${feature_num}-"*; do + [ -d "$spec_path" ] && return 0 + done + return 1 +} + # Function to clean and format a branch name clean_branch_name() { local name="$1" echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } +# Fit a feature prefix and suffix within GitHub's branch-name limit. +fit_branch_name() { + local feature_num="$1" + local branch_suffix="$2" + local branch_name="${feature_num}-${branch_suffix}" + + if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then + local prefix_length=$(( ${#feature_num} + 1 )) + local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length)) + local truncated_suffix + truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//') + branch_name="${feature_num}-${truncated_suffix}" + fi + + printf '%s' "$branch_name" +} + +# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote +# so the persistence hints match the Python variant exactly (printf %q output +# differs between bash versions and from shlex.quote for spaces/metachars). +shell_quote() { + local value="$1" LC_ALL=C + if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then + printf '%s' "$value" + else + local q="'\"'\"'" + printf "'%s'" "${value//\'/$q}" + fi +} + # Resolve repository root using common.sh functions which prioritize .specify SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" @@ -135,35 +196,37 @@ fi # Function to generate branch name with stop word filtering and length filtering generate_branch_name() { local description="$1" - + # Common stop words to filter out local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" - + # Convert to lowercase and split into words - local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') - + local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) local meaningful_words=() for word in $clean_name; do # Skip empty words [ -z "$word" ] && continue - + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) if ! echo "$word" | grep -qiE "$stop_words"; then if [ ${#word} -ge 3 ]; then meaningful_words+=("$word") - elif echo "$description" | grep -q "\b${word^^}\b"; then - # Keep short words if they appear as uppercase in original (likely acronyms) + # Keep short words that appear as an uppercase acronym in the original. + # Uppercase via tr and match with grep -w (both portable) rather than + # bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX). + elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then meaningful_words+=("$word") fi fi done - + # If we have meaningful words, use first 3-4 of them if [ ${#meaningful_words[@]} -gt 0 ]; then local max_words=3 if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi - + local result="" local count=0 for word in "${meaningful_words[@]}"; do @@ -200,34 +263,64 @@ if [ "$USE_TIMESTAMP" = true ]; then FEATURE_NUM=$(date +%Y%m%d-%H%M%S) BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" else + if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2 + exit 1 + fi + + # Bash arithmetic is signed 64-bit; reject digit strings that would wrap. + if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then + echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2 + exit 1 + fi + # Determine branch number from existing feature directories if [ -z "$BRANCH_NUMBER" ]; then HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 + exit 1 + fi BRANCH_NUMBER=$((HIGHEST + 1)) fi # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + + # Treat an explicit number as a preference when its prefix is already used + # by a feature directory. Auto-detected numbers are already conflict-free. + if [ "$NUMBER_EXPLICIT" = true ]; then + SPEC_CONFLICT=false + REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") + REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME" + if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true + fi + + if [ "$SPEC_CONFLICT" = true ]; then + REQUESTED_NUM="$FEATURE_NUM" + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$HIGHEST + while true; do + if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 + exit 1 + fi + BRANCH_NUMBER=$((BRANCH_NUMBER + 1)) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break + done + >&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead" + fi + fi + fi # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary -MAX_BRANCH_LENGTH=244 -if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then - # Calculate how much we need to trim from suffix - # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 - PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) - - # Truncate suffix at word boundary if possible - TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) - # Remove trailing hyphen if truncation created one - TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - - ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" - +ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") +if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" @@ -262,8 +355,8 @@ if [ "$DRY_RUN" != true ]; then _persist_feature_json "$REPO_ROOT" "$FEATURE_DIR" # Inform the user how to set feature state in their own shell - printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 - printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2 + printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2 + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2 fi if $JSON_MODE; then @@ -293,7 +386,7 @@ else echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" if [ "$DRY_RUN" != true ]; then - printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" - printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" + printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" fi fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh index cb67943..e01dc44 100755 --- a/.specify/scripts/bash/setup-plan.sh +++ b/.specify/scripts/bash/setup-plan.sh @@ -8,17 +8,17 @@ ARGS=() for arg in "$@"; do case "$arg" in - --json) - JSON_MODE=true + --json) + JSON_MODE=true ;; - --help|-h) + --help|-h) echo "Usage: $0 [--json]" echo " --json Output results in JSON format" echo " --help Show this help message" - exit 0 + exit 0 ;; - *) - ARGS+=("$arg") + *) + ARGS+=("$arg") ;; esac done @@ -77,8 +77,7 @@ if $JSON_MODE; then fi else echo "FEATURE_SPEC: $FEATURE_SPEC" - echo "IMPL_PLAN: $IMPL_PLAN" + echo "IMPL_PLAN: $IMPL_PLAN" echo "SPECS_DIR: $FEATURE_DIR" echo "BRANCH: $CURRENT_BRANCH" fi - diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md index c4aa166..e64065d 100644 --- a/.specify/templates/checklist-template.md +++ b/.specify/templates/checklist-template.md @@ -6,16 +6,16 @@ **Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements. - diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index 92b96c7..be1aa88 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -4,7 +4,7 @@ **Input**: Feature specification from `/specs/[###-feature-name]/spec.md` -**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow. +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. ## Summary diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index 804427e..ceb2877 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -8,20 +8,6 @@ **Input**: User description: "$ARGUMENTS" -> Every requirement and AC MUST carry a durable ID per `traceability.md`. IDs are assigned at the PRD level in `HomesFlow.prd.md` — feature specs inherit them; do not mint new IDs here. - -## Intended Use - -[One paragraph: who uses this feature, for what purpose, in what context.] - -## Risk & failure modes - -Per `traceability.md` §9.3 — list material failures, user impact, and mitigations traced to FR/AC IDs. - -| Failure | User impact | Mitigation / trace | -|---------|-------------|-------------------| -| [e.g. sync conflict on step status] | [e.g. surprising checklist state] | [e.g. AC-PROC-03, AC-SYNC-01] | - ## User Scenarios & Testing *(mandatory)*