diff --git a/README.md b/README.md index 0714658..f2941b8 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,12 @@ Uninstall with `./scripts/uninstall.sh` (`--dry-run` to preview). | `devloop status` | Show run status | | `devloop clean` | Remove run artifacts | -Each run writes an HTML report, spec, and reviews under `.devloop/`. +Each run writes a Markdown report, spec, and reviews under `.devloop/`. When you pick a spec from the interactive menu, devloop uses your configured run defaults and only prompts for PR mode. The interactive **Settings** menu sets the default coder and reviewer agents (Codex or Claude Code), spec path, and run timeout, saved to `~/.devloop/config`. Use CLI flags such as `--coder`, `--reviewer`, `--in-place`, or `--timeout-minutes` to override those defaults per run. ## Specs -A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec/references/spec-template.md`](skills/devloop-spec/references/spec-template.md). The bundled `devloop-spec` skill can also render a sibling HTML companion with [`skills/devloop-spec/scripts/render.sh`](skills/devloop-spec/scripts/render.sh). +A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec/references/spec-template.md`](skills/devloop-spec/references/spec-template.md). Strict mode is on by default: specs need `## Acceptance criteria`, and reviews must pass both the spec gate and engineering quality gate. @@ -57,7 +57,7 @@ Strict mode is on by default: specs need `## Acceptance criteria`, and reviews m Devloop ships two agent skills, installed into `~/.claude/skills` and `~/.agents/skills`: -- [`devloop-spec`](skills/devloop-spec/SKILL.md) — turns a rough idea, notes, a URL, or an interview into one concrete, devloop-ready spec, with optional HTML rendering. +- [`devloop-spec`](skills/devloop-spec/SKILL.md) — turns a rough idea, notes, a URL, or an interview into one concrete, devloop-ready spec. - [`devloop-review`](skills/devloop-review/SKILL.md) — judges each pass against the spec and engineering quality gates, returning ACCEPT, REJECT, or UNCLEAR with fix instructions. ## Runtime diff --git a/devloop b/devloop index e17685f..0758319 100755 --- a/devloop +++ b/devloop @@ -193,7 +193,6 @@ Common commands: devloop .devloop/specs/change.md devloop --tui .devloop/specs/change.md devloop --plain .devloop/specs/change.md - devloop --report-format markdown .devloop/specs/change.md 3 devloop --coder claude --reviewer codex .devloop/specs/change.md devloop --create-pr .devloop/specs/change.md @@ -202,7 +201,6 @@ Options: --plain force plain output --coder codex|claude choose Codex or Claude Code for implementation (default from Settings) --reviewer codex|claude choose Codex or Claude Code for review (default from Settings) - --report-format html|markdown choose report format --timeout-minutes N cap one run, default 30 --no-strict weaken strict review gates --in-place run in the current worktree @@ -237,7 +235,6 @@ welcome_tui() { printf ' %-30s %s\n' "--plain" "force plain output" printf ' %-30s %s\n' "--coder codex|claude" "choose implementation agent (default from Settings)" printf ' %-30s %s\n' "--reviewer codex|claude" "choose review agent (default from Settings)" - printf ' %-30s %s\n' "--report-format html|markdown" "choose report format" printf ' %-30s %s\n' "--timeout-minutes N" "cap one run, default 30" printf ' %-30s %s\n' "--no-strict" "weaken strict review gates" printf ' %-30s %s\n' "--in-place" "run in the current worktree" @@ -249,7 +246,7 @@ welcome_tui() { } usage() { - printf '%s\n' "usage: devloop [--version] [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--no-shell|--stay|--shell|--enter-worktree] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] [--timeout-minutes N] [max=5]" + printf '%s\n' "usage: devloop [--version] [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--no-shell|--stay|--shell|--enter-worktree] [--coder codex|claude] [--reviewer codex|claude] [--timeout-minutes N] [max=5]" printf '%s\n' "agents: Codex or Claude Code" } @@ -1295,13 +1292,12 @@ format_elapsed() { run_header() { local spec="$1" local max="$2" - local report_format="$3" - local strict="$4" - local use_worktree="$5" - local coder="$6" - local reviewer="$7" - local create_pr="$8" - local timeout_minutes="${9:-$DEFAULT_TIMEOUT_MINUTES}" + local strict="$3" + local use_worktree="$4" + local coder="$5" + local reviewer="$6" + local create_pr="$7" + local timeout_minutes="${8:-$DEFAULT_TIMEOUT_MINUTES}" if [ "$USE_TUI" != true ]; then return; fi ui_header "devloop" "$spec" ui_print_key_values \ @@ -1311,7 +1307,6 @@ run_header() { "passes" "$max" \ "timeout" "$timeout_minutes minutes" \ "strict" "$strict" \ - "report" "$report_format" \ "pr" "$create_pr" } @@ -1528,7 +1523,6 @@ interactive_create_pr_choice() { interactive_run_setup() { local spec="$1" - local report_format="html" local strict=true local use_worktree=true local coder @@ -1550,8 +1544,8 @@ interactive_run_setup() { ui_go_back return 0 fi - run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" - run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_header "$spec" "$max" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_devloop "$spec" "$max" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" code=$? maybe_enter_worktree return "$(final_exit_code "$code")" @@ -1782,21 +1776,13 @@ list_artifact_files() { while IFS= read -r root; do dir="$root/$subdir" if [ -d "$dir" ]; then - find "$dir" -type f | LC_ALL=C sort + find "$dir" -type f -name '*.md' | LC_ALL=C sort fi done } view_file() { local file="$1" - case "$file" in - *.html|*.htm) - if [ "$USE_TUI" = true ] && [ -t 0 ]; then - if command -v open >/dev/null 2>&1 && open "$file" >/dev/null 2>&1; then return 0; fi - if command -v xdg-open >/dev/null 2>&1 && xdg-open "$file" >/dev/null 2>&1; then return 0; fi - fi - ;; - esac if ui_has_gum; then gum pager < "$file" return $? @@ -1870,12 +1856,8 @@ track_report_path() { local slug dir report slug="$(basename "$track" .md)" dir="$(cd "$(dirname "$track")/.." >/dev/null 2>&1 && pwd -P)/reports" - for report in "$dir/$slug.html" "$dir/$slug.md"; do - if [ -f "$report" ]; then - printf '%s\n' "$report" - return - fi - done + report="$dir/$slug.md" + if [ -f "$report" ]; then printf '%s\n' "$report"; fi } print_track_status() { @@ -1956,7 +1938,7 @@ remove_devloop_worktree() { run_from_track() { local track="$1" - local worktree spec max report_format strict coder reviewer create_pr timeout_minutes old_pwd code next_pass + local worktree spec max strict coder reviewer create_pr timeout_minutes old_pwd code next_pass track="$(absolute_existing_file "$track")" || { printf 'track not found: %s\n' "$track" >&2 return 2 @@ -1964,7 +1946,6 @@ run_from_track() { worktree="$(track_value "worktree" "$track")" spec="$(track_value "spec" "$track")" max="$(track_value "max" "$track")" - report_format="$(track_value "report-format" "$track")" strict="$(track_value "strict" "$track")" coder="$(track_value "coder" "$track")" reviewer="$(track_value "reviewer" "$track")" @@ -1974,7 +1955,6 @@ run_from_track() { if [ -z "$worktree" ]; then worktree="$(cd "$(dirname "$track")/../.." >/dev/null 2>&1 && pwd -P)"; fi if [ -z "$spec" ]; then spec="$(track_value "source-spec" "$track")"; fi if [ -z "$max" ]; then max=5; fi - if [ -z "$report_format" ]; then report_format="html"; fi if [ -z "$strict" ]; then strict=true; fi if [ -z "$coder" ]; then coder="$(devloop_coder)"; fi if [ -z "$reviewer" ]; then reviewer="$(devloop_reviewer)"; fi @@ -1995,8 +1975,8 @@ run_from_track() { old_pwd="$PWD" cd "$worktree" || return 2 RUN_START_PASS="$next_pass" - run_header "$spec" "$max" "$report_format" "$strict" false "$coder" "$reviewer" "$create_pr" "$timeout_minutes" - run_devloop "$spec" "$max" "$report_format" "$strict" false "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_header "$spec" "$max" "$strict" false "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_devloop "$spec" "$max" "$strict" false "$coder" "$reviewer" "$create_pr" "$timeout_minutes" code=$? RUN_START_PASS=1 maybe_enter_worktree @@ -2045,7 +2025,6 @@ will_enter_worktree_shell() { } run_command() { - local report_format="html" local strict=true local use_worktree=true local coder @@ -2064,16 +2043,6 @@ run_command() { arg="$1" shift case "$arg" in - --report-format) - if [ "$#" -eq 0 ]; then usage >&2; return 2; fi - value="$1" - shift - case "$value" in - html) report_format="html" ;; - markdown|md) report_format="markdown" ;; - *) usage >&2; return 2 ;; - esac - ;; --coder) if [ "$#" -eq 0 ]; then printf '%s\n' "coder must be Codex or Claude Code" >&2; usage >&2; return 2; fi value="$(normalize_agent "$1")" @@ -2096,8 +2065,6 @@ run_command() { fi shift ;; - --html) report_format="html" ;; - --markdown|--md) report_format="markdown" ;; --no-strict) strict=false ;; --strict) strict=true ;; --in-place) use_worktree=false ;; @@ -2136,8 +2103,8 @@ run_command() { if [ "$max" -lt 1 ]; then max=1; fi if [ "$max" -gt 10 ]; then max=10; fi - run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" - run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_header "$spec" "$max" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" + run_devloop "$spec" "$max" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" local code=$? maybe_enter_worktree return "$(final_exit_code "$code")" @@ -2146,13 +2113,12 @@ run_command() { run_devloop() { local spec_arg="$1" local max="$2" - local report_format="$3" - local strict="$4" - local use_worktree="$5" - local coder="$6" - local reviewer="$7" - local create_pr="$8" - local timeout_minutes="${9:-$DEFAULT_TIMEOUT_MINUTES}" + local strict="$3" + local use_worktree="$4" + local coder="$5" + local reviewer="$6" + local create_pr="$7" + local timeout_minutes="${8:-$DEFAULT_TIMEOUT_MINUTES}" MAX="$max" CODER="$coder" @@ -2262,15 +2228,11 @@ run_devloop() { run_branch="$(git -C "$repo" branch --show-current)" FINAL_BRANCH="$run_branch" TRACK=".devloop/tracks/$slug.md" - if [ "$report_format" = "html" ]; then - REPORT=".devloop/reports/$slug.html" - else - REPORT=".devloop/reports/$slug.md" - fi + REPORT=".devloop/reports/$slug.md" local coder_session=".devloop/sessions/$slug-coder-$coder.id" local reviewer_session=".devloop/sessions/$slug-reviewer-$reviewer.id" - init_track "$repo/$TRACK" "$run_spec" "$spec" "$PWD" "$SOURCE_REPO" "$repo" "$base" "$source_branch" "$run_branch" "$max" "$report_format" "$strict" "$coder" "$reviewer" "$WORK_TYPE" "$WORK_BREAKING" "$create_pr" "$timeout_minutes" + init_track "$repo/$TRACK" "$run_spec" "$spec" "$PWD" "$SOURCE_REPO" "$repo" "$base" "$source_branch" "$run_branch" "$max" "$strict" "$coder" "$reviewer" "$WORK_TYPE" "$WORK_BREAKING" "$create_pr" "$timeout_minutes" if [ "$create_pr" = true ]; then event_step "pull-request-lookup" "checking for existing PR" @@ -2448,7 +2410,7 @@ run_devloop() { CODER_SESSION_ID="$(read_first_line "$repo/$coder_session")" REVIEWER_SESSION_ID="$(read_first_line "$repo/$reviewer_session")" - synthesize_report "$repo" "$slug" "$reviewer" "$run_spec" "$spec" "$spec_text" "$SOURCE_REPO" "$repo" "$TRACK" "$REPORT" "$STATUS" "$PASSES" "$max" "$base" "$source_branch" "$FINAL_BRANCH" "$FINAL_COMMIT" "$FINAL_COMMIT_MESSAGE" "$PULL_REQUEST" "$PULL_REQUEST_ERROR" "$coder" "$repo/$reviewer_session" "$CODER_SESSION_ID" "$REVIEWER_SESSION_ID" "$report_format" + synthesize_report "$repo" "$slug" "$reviewer" "$run_spec" "$spec" "$spec_text" "$SOURCE_REPO" "$repo" "$TRACK" "$REPORT" "$STATUS" "$PASSES" "$max" "$base" "$source_branch" "$FINAL_BRANCH" "$FINAL_COMMIT" "$FINAL_COMMIT_MESSAGE" "$PULL_REQUEST" "$PULL_REQUEST_ERROR" "$coder" "$repo/$reviewer_session" "$CODER_SESSION_ID" "$REVIEWER_SESSION_ID" if [ "$create_pr" = true ] && [ -n "$PULL_REQUEST" ] && [ "$STATUS" != "pr-error" ]; then event_step "pr-final-report" "posting final report to PR" @@ -3176,14 +3138,13 @@ init_track() { local branch="$8" local worktree_branch="$9" local max="${10}" - local report_format="${11}" - local strict="${12}" - local coder="${13}" - local reviewer="${14}" - local type="${15}" - local breaking="${16}" - local create_pr="${17}" - local timeout_minutes="${18}" + local strict="${11}" + local coder="${12}" + local reviewer="${13}" + local type="${14}" + local breaking="${15}" + local create_pr="${16}" + local timeout_minutes="${17}" local track_name if [ -f "$file" ]; then return; fi track_name="$(basename "$file" .md)" @@ -3203,7 +3164,6 @@ init_track() { - type: $type - breaking: $breaking - max: $max -- report-format: $report_format - strict: $strict - create-pr: $create_pr - timeout-minutes: $timeout_minutes @@ -4103,17 +4063,12 @@ synthesize_report() { local reviewer_session_file="${22}" local coder_session_id="${23}" local reviewer_session_id="${24}" - local format="${25}" local title subtitle reviews metadata body prompt title="$(report_title "$spec_text" "$slug")" subtitle="$(report_subtitle "$spec_text" "$title")" reviews="$(list_reviews "$slug" "$pass" "$max")" metadata="$(report_metadata "$status" "$pass" "$max" "$repo" "$run_spec" "$source_spec" "$source_repo" "$worktree" "$base" "$initial_branch" "$branch" "$commit" "$commit_message" "$pull_request" "$pull_request_error" "$coder" "$reviewer" "$coder_session_id" "$reviewer_session_id" "$track" "$reviews")" - if [ "$format" = "html" ]; then - body="Write the report to $report as valid standalone HTML. Use a readable document layout with embedded CSS, set the HTML to the report title, render the report title and subtitle before Metadata, render a topical three-line haiku immediately after the subtitle, use a compact metadata table, and add substantive sections after it. Include these visible section headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run." - else - body="Write the report to $report in markdown. Start with the report title as the H1, put the subtitle directly below it, put a topical three-line haiku immediately after the subtitle, then include these headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run." - fi + body="Write the report to $report in plain Markdown. Start with the report title as the H1, put the subtitle directly below it, put a topical three-line haiku immediately after the subtitle, then include these headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Use terminal-friendly Markdown only: no HTML tags, embedded CSS, Mermaid, SVG, images, or external assets. If a diagram clarifies the run, draw it as ASCII inside a plain code fence. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run." prompt="$(cat <<EOF You are writing a learning-oriented post-mortem for a developer who just ran a devloop. diff --git a/scripts/devloop_test.sh b/scripts/devloop_test.sh index fee9cac..67be55e 100755 --- a/scripts/devloop_test.sh +++ b/scripts/devloop_test.sh @@ -36,22 +36,7 @@ equals() { [[ "$actual" == "$expected" ]] || fail "$label expected [$expected], got [$actual]" } -count_occurrences() { - local file="$1" - local needle="$2" - awk -v needle="$needle" ' - { - line = $0 - while ((idx = index(line, needle)) > 0) { - count++ - line = substr(line, idx + length(needle)) - } - } - END { print count + 0 } - ' "$file" -} - -bash -n "$REPO_ROOT/devloop" "$SCRIPTS_DIR/install.sh" "$SCRIPTS_DIR/uninstall.sh" "$SCRIPTS_DIR/skill_helpers.sh" "$SCRIPTS_DIR/release.sh" "$REMOTE_INSTALLER" "$REPO_ROOT/site/public/install" "$REPO_ROOT/skills/devloop-spec/scripts/render.sh" +bash -n "$REPO_ROOT/devloop" "$SCRIPTS_DIR/install.sh" "$SCRIPTS_DIR/uninstall.sh" "$SCRIPTS_DIR/skill_helpers.sh" "$SCRIPTS_DIR/release.sh" "$REMOTE_INSTALLER" "$REPO_ROOT/site/public/install" ok "bash syntax" DEVLOOP_LIB=1 @@ -80,6 +65,8 @@ contains "$help" "--no-shell" "help" contains "$help" "--enter-worktree" "help" contains "$help" "--version" "help" contains "$help" "--timeout-minutes" "help" +not_contains "$help" "--report-format" "help" +not_contains "$help" "--html" "help" ok "help output" remote_help="$("$REMOTE_INSTALLER" --help)" @@ -99,6 +86,10 @@ contains "$readme_text" "cd devloop" "README source install" contains "$readme_text" "./scripts/install.sh" "README source install" contains "$readme_text" "\`devloop update\`" "README command table" not_contains "$readme_text" "\`devloop $obsolete_update_command\`" "README command table" +not_contains "$readme_text" "render.py" "README Python spec renderer" +not_contains "$readme_text" "render.sh" "README spec renderer" +not_contains "$readme_text" "sibling HTML companion" "README spec renderer" +not_contains "$readme_text" "HTML report" "README report docs" ok "README install docs" skill_path="$("$REPO_ROOT/devloop" spec --skill-path)" @@ -106,6 +97,22 @@ skill_path="$("$REPO_ROOT/devloop" spec --skill-path)" contains "$("$REPO_ROOT/devloop" spec --print-skill)" "name: devloop-spec" "spec skill" ok "spec skill path" +spec_skill_text="$(cat "$REPO_ROOT/skills/devloop-spec/SKILL.md")" +spec_template_text="$(cat "$REPO_ROOT/skills/devloop-spec/references/spec-template.md")" +contains "$spec_skill_text" "ASCII" "spec skill ASCII diagrams" +contains "$spec_skill_text" "plain code fence" "spec skill plain diagram fence" +not_contains "$spec_skill_text" "mermaid" "spec skill Mermaid guidance" +not_contains "$spec_skill_text" "Mermaid" "spec skill Mermaid guidance" +not_contains "$spec_skill_text" "render.py" "spec skill Python renderer" +not_contains "$spec_skill_text" "python3" "spec skill Python renderer" +not_contains "$spec_skill_text" "render.sh" "spec skill renderer" +not_contains "$spec_skill_text" "HTML companion" "spec skill renderer" +contains "$spec_template_text" $'\n```\n current' "spec template plain ASCII fence" +contains "$spec_template_text" "Current behavior -> Implementation change -> Expected outcome" "spec template ASCII schematic" +not_contains "$spec_template_text" '```mermaid' "spec template Mermaid fence" +not_contains "$spec_template_text" "flowchart LR" "spec template Mermaid syntax" +ok "spec ASCII diagram guidance" + contains "$(cat "$REPO_ROOT/README.md")" "\`devloop --create-pr <spec.md>\`" "README PR mode" contains "$(cat "$REPO_ROOT/README.md")" "maintain a draft PR (requires \`gh\`)" "README PR mode" ok "README PR guidance" @@ -149,113 +156,6 @@ ok "skill metadata" work=$(mktemp -d "${TMPDIR:-/tmp}/devloop-test.XXXXXX") trap 'rm -rf "$work"' EXIT -renderer_script="$REPO_ROOT/skills/devloop-spec/scripts/render.sh" -[[ -x "$renderer_script" ]] || fail "missing executable bash spec renderer" - -regular_renderer_fixture="$work/spec-render-fixture.md" -cat > "$regular_renderer_fixture" <<'SPEC' ---- -status: draft -type: feat -created: 2026-06-16 -pr: null ---- - -# Renderer Fixture -Render the spec with light styling and robust escaping. - -## Problem -Renderer regressions are hard to spot from markdown alone. - -```markdown -## This is inside a code fence -- not a real section -</pre><script>alert("x")</script>& -``` - -The paragraph after the fenced heading still belongs to Problem. - -## Outcome -The HTML companion renders the spec sections. - -## Scope -- In: renderer fixture -- Out: browser automation - -## Behavior -### Happy path -1. Run the bundled renderer. -2. HTML is written next to the markdown. - -### Edge cases -- HTML-sensitive fenced content is escaped. - -## Acceptance criteria -1. The generated HTML uses the light theme. - -## Test plan -- Red: Not applicable for fixture. -- Green: `skills/devloop-spec/scripts/render.sh <fixture>` -- Full: `bash scripts/devloop_test.sh` -- Coverage: Not applicable for Bash fixture. - -## Constraints -- Must: keep markdown canonical. -- Avoid: editing generated HTML by hand. -- Existing convention: derived files sit beside the source markdown. - -## Notes -No gaps. -SPEC -if "$renderer_script" >/tmp/devloop-renderer-usage.out 2>&1; then - fail "spec renderer accepted missing argument" -fi -if "$renderer_script" "$regular_renderer_fixture" "$regular_renderer_fixture" >/tmp/devloop-renderer-usage.out 2>&1; then - fail "spec renderer accepted extra argument" -fi -renderer_output="$("$renderer_script" "$regular_renderer_fixture")" -[[ -f "$renderer_output" ]] || fail "spec renderer did not create HTML" -contains "$(cat "$renderer_output")" "--bg: #ffffff" "spec renderer light theme" -not_contains "$(cat "$renderer_output")" '<summary><span class="chev">▶</span>This is inside a code fence</summary>' "spec renderer fenced heading" -contains "$(cat "$renderer_output")" '</pre><script>alert("x")</script>&' "spec renderer escaped fenced HTML" -contains "$(cat "$renderer_output")" "The paragraph after the fenced heading still belongs to Problem." "spec renderer fenced content" -contains "$(cat "$renderer_output")" $'<details class="section open" open>\n <summary><span class="chev">▶</span>Acceptance criteria</summary>' "spec renderer acceptance open" -not_contains "$(cat "$renderer_output")" "mermaid.esm.min.mjs" "spec renderer without Mermaid" -expected_renderer_output="$(cd -P "$(dirname "$regular_renderer_fixture")" >/dev/null 2>&1 && pwd)/$(basename "${regular_renderer_fixture%.md}.html")" -equals "$renderer_output" "$expected_renderer_output" "spec renderer output path" - -mermaid_renderer_fixture="$work/spec-render-mermaid-fixture.md" -cat > "$mermaid_renderer_fixture" <<'SPEC' ---- -status: draft -type: feat -created: 2026-06-16 -pr: null ---- - -# Mermaid Renderer Fixture -Render Mermaid fences without requiring local dependencies. - -```mermaid -flowchart LR - Modes["Gmail/Outlook | IMAP"] --> Result["Rendered HTML"] - Danger["</pre>"] --> Result -``` - -## Problem -Mermaid should render in the browser when diagrams are present. - -## Acceptance criteria -1. The generated HTML imports Mermaid exactly once. -SPEC -mermaid_renderer_output="$("$renderer_script" "$mermaid_renderer_fixture")" -[[ -f "$mermaid_renderer_output" ]] || fail "Mermaid spec renderer did not create HTML" -contains "$(cat "$mermaid_renderer_output")" '<pre class="mermaid">' "spec renderer Mermaid pre" -contains "$(cat "$mermaid_renderer_output")" 'Modes["Gmail/Outlook | IMAP"]' "spec renderer Mermaid pass-through" -contains "$(cat "$mermaid_renderer_output")" 'Danger["</pre>"]' "spec renderer Mermaid escaping" -equals "$(count_occurrences "$mermaid_renderer_output" "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")" "1" "spec renderer Mermaid import count" -ok "spec renderer" - equals "$(sed -n '1p' "$REPO_ROOT/site/public/VERSION")" "$version" "site VERSION matches root VERSION" ok "site version file" @@ -821,7 +721,7 @@ if ! run_setup_output="$( maybe_enter_worktree() { :; } interactive_run_setup "spec.md" )"; then fail "run setup defaults failed"; fi -equals "$run_setup_output" "spec.md 5 html true true codex claude false 60" "run setup launches with defaults" +equals "$run_setup_output" "spec.md 5 true true codex claude false 60" "run setup launches with defaults" configured_agents_home="$work/agents-home" configured_agents_repo="$work/agents-repo" mkdir -p "$configured_agents_home" "$configured_agents_repo/.devloop/specs" @@ -1299,12 +1199,12 @@ PATH="$install_path" command -v gum >/dev/null 2>&1 || fail "installer did not m PATH="$install_path" command -v fzf >/dev/null 2>&1 || fail "installer did not make fzf available" [[ -f "$install_home/.agents/skills/devloop-spec/SKILL.md" ]] || fail "installer did not install Codex spec skill" [[ -f "$install_home/.agents/skills/devloop-spec/references/spec-template.md" ]] || fail "installer did not install Codex spec template reference" -[[ -x "$install_home/.agents/skills/devloop-spec/scripts/render.sh" ]] || fail "installer did not install Codex spec renderer" +[[ ! -e "$install_home/.agents/skills/devloop-spec/scripts/render.sh" ]] || fail "installer installed removed Codex spec renderer" [[ ! -e "$install_home/.agents/skills/devloop-spec/scripts/render.py" ]] || fail "installer installed removed Codex Python spec renderer" [[ -f "$install_home/.agents/skills/devloop-review/SKILL.md" ]] || fail "installer did not install Codex review skill" [[ -f "$install_home/.agents/skills/devloop-review/.devloop-checksum" ]] || fail "installer did not write Codex checksum" [[ -f "$install_home/.claude/skills/devloop-spec/SKILL.md" ]] || fail "installer did not install Claude spec skill" -[[ -x "$install_home/.claude/skills/devloop-spec/scripts/render.sh" ]] || fail "installer did not install Claude spec renderer" +[[ ! -e "$install_home/.claude/skills/devloop-spec/scripts/render.sh" ]] || fail "installer installed removed Claude spec renderer" [[ ! -e "$install_home/.claude/skills/devloop-spec/scripts/render.py" ]] || fail "installer installed removed Claude Python spec renderer" [[ -f "$install_home/.claude/skills/devloop-review/SKILL.md" ]] || fail "installer did not install Claude review skill" [[ -f "$install_home/.claude/skills/devloop-review/.devloop-checksum" ]] || fail "installer did not write Claude checksum" @@ -1938,12 +1838,15 @@ contains "$accept_output" "accepted" "accept loop" accept_worktree="$(printf '%s\n' "$accept_output" | sed -nE 's/^[[:space:]]*Worktree[[:space:]]+//p')" [[ -f "$accept_worktree/result.txt" ]] || fail "accept loop did not write result" contains "$accept_output" "Open Next" "accept loop" -if ! printf '%s\n' "$accept_output" | grep -F "Report" | grep -F "$accept_worktree/.devloop/reports/e2e-accept.html" >/dev/null; then +if ! printf '%s\n' "$accept_output" | grep -F "Report" | grep -F "$accept_worktree/.devloop/reports/e2e-accept.md" >/dev/null; then fail "accept loop missing worktree-qualified report path" fi +[[ -f "$accept_worktree/.devloop/reports/e2e-accept.md" ]] || fail "accept loop did not write markdown report" +[[ ! -e "$accept_worktree/.devloop/reports/e2e-accept.html" ]] || fail "accept loop wrote html report" if ! printf '%s\n' "$accept_output" | grep -F "Track" | grep -F "$accept_worktree/.devloop/tracks/e2e-accept.md" >/dev/null; then fail "accept loop missing worktree-qualified track path" fi +not_contains "$(cat "$accept_worktree/.devloop/tracks/e2e-accept.md")" "report-format" "accept loop track metadata" contains "$(cat "$accept_worktree/.devloop/logs/e2e-accept-r1-verify.log")" "verify pass" "verify hook" contains "$(run_repo_main "$loop_repo" status)" "e2e-accept" "status command" contains "$(run_repo_main "$loop_repo" clean --dry-run)" "skip:" "clean skips accepted" diff --git a/skills/devloop-spec/SKILL.md b/skills/devloop-spec/SKILL.md index 3134db3..e67e6c2 100644 --- a/skills/devloop-spec/SKILL.md +++ b/skills/devloop-spec/SKILL.md @@ -11,12 +11,11 @@ Produce exactly one implementation spec that conforms to the devloop standard. T Use this skill for both cold-start interviews and distilling existing material. Do not hand off to a separate interview or spec-writing skill. -The markdown spec is the source of truth that `devloop` will use as implementation input. If a file is written and the environment supports it, also render the optional HTML companion. +The markdown spec is the source of truth that `devloop` will use as implementation input. Author diagrams as ASCII art inside plain code fences so terminal readers, GitHub, and plain text show the same schematic. Available resources: - `references/spec-template.md`: read when drafting or validating the spec shape. -- `scripts/render.sh`: run after writing a markdown spec to create a sibling HTML companion. ## Scope Guard @@ -70,7 +69,7 @@ If the spec depends on something cheap to verify, verify it rather than assertin Read `references/spec-template.md` when creating the draft. Every section appears in this order. Frontmatter uses exactly these fields: -```markdown +````markdown --- status: draft type: feat|fix|chore @@ -81,10 +80,10 @@ pr: null # <Concise title> <One-sentence subtitle that names the implementation slice and why it matters.> -```mermaid -flowchart LR - Current["Current behavior"] --> Change["Implementation change"] - Change --> Result["Expected outcome"] +``` + current change result + ------- ------ ------ + Current behavior -> Implementation change -> Expected outcome ``` ## Problem @@ -121,14 +120,14 @@ flowchart LR ## Notes <Only material implementation hints, risks, dependencies, migrations, or open questions.> -``` +```` - `created` is today's date. - Infer `type`: `feat` for new capability, `fix` for broken behavior, and `chore` for maintenance, docs, tests, dependencies, or refactors. - The H1 is a concise title, not a sentence. - The subtitle is a plain one-sentence line directly under the H1. -- The Mermaid schematic is optional but preferred when it clarifies architecture, data flow, ownership, or before/after behavior. Omit it if it would be decorative or speculative. -- In Mermaid flowchart node labels, quote labels containing `|`. Use `Node["A | B"]`, not `Node[A | B]`. +- The ASCII schematic is optional but preferred when it clarifies architecture, data flow, ownership, or before/after behavior. Omit it if it would be decorative or speculative. +- Put schematics inside a plain code fence: three backticks on a line by themselves, ASCII diagram lines, then three closing backticks. - Under `## Behavior`, use `### Happy path` and `### Edge cases` H3 headings, not plain `Happy path:` labels. - Acceptance criteria must be singular, verifiable, and observable. - Include concrete paths, commands, APIs, and behaviors when the context provides them. @@ -157,18 +156,6 @@ Do not hard-code personal paths. Use this precedence: Do not wrap the spec in a code fence unless the caller explicitly asks for a fenced snippet. -## HTML Companion - -When a markdown spec is written to a file, render the interactive HTML companion if the bundled script is available: - -```bash -scripts/render.sh <path-to-spec.md> -``` - -Run the command from the skill directory, or resolve `scripts/render.sh` relative to this skill's `SKILL.md`. The script writes `<path-to-spec>.html` next to the markdown. - -If Mermaid fails in the browser, fix the markdown source and rerun the renderer. If rendering cannot run in the current environment, keep the markdown spec and say HTML was not generated. - ## Signoff -After writing, report the spec path, HTML path if generated, inferred `type`, acceptance criteria, and remaining gaps. Offer `devloop --create-pr <spec path>` as the next handoff when a file path exists. +After writing, report the spec path, inferred `type`, acceptance criteria, and remaining gaps. Offer `devloop --create-pr <spec path>` as the next handoff when a file path exists. diff --git a/skills/devloop-spec/references/spec-template.md b/skills/devloop-spec/references/spec-template.md index 136da50..789df13 100644 --- a/skills/devloop-spec/references/spec-template.md +++ b/skills/devloop-spec/references/spec-template.md @@ -8,10 +8,10 @@ pr: null # <Concise title> <One-sentence subtitle that names the implementation slice and why it matters.> -```mermaid -flowchart LR - Current["Current behavior"] --> Change["Implementation change"] - Change --> Result["Expected outcome"] +``` + current change result + ------- ------ ------ + Current behavior -> Implementation change -> Expected outcome ``` ## Problem diff --git a/skills/devloop-spec/scripts/render.sh b/skills/devloop-spec/scripts/render.sh deleted file mode 100755 index f976dac..0000000 --- a/skills/devloop-spec/scripts/render.sh +++ /dev/null @@ -1,405 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [ "$#" -ne 1 ]; then - printf 'usage: render.sh <spec.md>\n' >&2 - exit 2 -fi - -src="$1" -if [ ! -f "$src" ]; then - printf 'not found: %s\n' "$src" >&2 - exit 1 -fi - -src_dir="$(cd -P "$(dirname "$src")" >/dev/null 2>&1 && pwd)" -src_base="$(basename "$src")" -src_path="$src_dir/$src_base" -out_path="$src_dir/${src_base%.*}.html" -tmp_path="$out_path.tmp.$$" - -cleanup() { - rm -f "$tmp_path" -} -trap cleanup EXIT - -awk -v src_path="$src_path" -v stem="${src_base%.*}" ' -function trim(s) { - sub(/^[[:space:]]+/, "", s) - sub(/[[:space:]]+$/, "", s) - return s -} - -function append_line(text, line) { - return text == "" ? line : text "\n" line -} - -function append_html(text, part) { - if (part == "") { - return text - } - return text == "" ? part : text "\n" part -} - -function html_escape(s) { - gsub(/&/, "\\&", s) - gsub(/</, "\\<", s) - gsub(/>/, "\\>", s) - gsub(/"/, "\\"", s) - return s -} - -function render_inline(s) { - return html_escape(s) -} - -function is_ordered(line) { - return line ~ /^[[:space:]]*[0-9]+[.][[:space:]]+/ -} - -function is_unordered(line) { - return line ~ /^[[:space:]]*-[[:space:]]+/ -} - -function render_list(block, title, lines, n, i, line, text, current, ordered, tag, class_attr, out, title_lower) { - n = split(block, lines, "\n") - ordered = is_ordered(lines[1]) - tag = ordered ? "ol" : "ul" - title_lower = tolower(title) - class_attr = "" - if (index(title_lower, "acceptance") > 0) { - class_attr = " class=\"ac\"" - } else if (ordered) { - class_attr = " class=\"steps\"" - } - out = "<" tag class_attr ">" - current = "" - for (i = 1; i <= n; i++) { - line = lines[i] - if ((ordered && is_ordered(line)) || (!ordered && is_unordered(line))) { - if (current != "") { - out = out "<li>" render_inline(current) "</li>" - } - text = line - if (ordered) { - sub(/^[[:space:]]*[0-9]+[.][[:space:]]+/, "", text) - } else { - sub(/^[[:space:]]*-[[:space:]]+(\[[[:space:]]\][[:space:]]+)?/, "", text) - } - current = trim(text) - } else if (trim(line) != "" && current != "") { - current = current " " trim(line) - } - } - if (current != "") { - out = out "<li>" render_inline(current) "</li>" - } - return out "</" tag ">" -} - -function render_code_block(block, lines, n, first, lang, content, end, i) { - n = split(block, lines, "\n") - first = lines[1] - lang = trim(substr(first, 4)) - content = "" - end = n - if (n > 1 && lines[n] ~ /^```/) { - end = n - 1 - } - for (i = 2; i <= end; i++) { - content = append_line(content, lines[i]) - } - if (lang == "mermaid") { - return "<pre class=\"mermaid\">" html_escape(content) "</pre>" - } - return "<pre><code>" html_escape(content) "</code></pre>" -} - -function render_block(block, title, lines, first, title_text, paragraph, i) { - block = trim(block) - if (block == "") { - return "" - } - split(block, lines, "\n") - first = lines[1] - if (first ~ /^```/) { - return render_code_block(block) - } - if (first ~ /^###[[:space:]]+/) { - title_text = first - sub(/^###[[:space:]]+/, "", title_text) - return "<h3>" render_inline(trim(title_text)) "</h3>" - } - if (is_unordered(first) || is_ordered(first)) { - return render_list(block, title) - } - if (trim(block) == "---") { - return "<hr>" - } - paragraph = "" - for (i = 1; i <= split(block, lines, "\n"); i++) { - paragraph = paragraph == "" ? trim(lines[i]) : paragraph " " trim(lines[i]) - } - return "<p>" render_inline(paragraph) "</p>" -} - -function render_blocks(text, title, lines, n, i, line, label, block, in_fence, out, title_lower) { - n = split(text, lines, "\n") - block = "" - in_fence = 0 - out = "" - title_lower = tolower(title) - for (i = 1; i <= n; i++) { - line = lines[i] - if (line ~ /^```/) { - if (in_fence) { - block = append_line(block, line) - in_fence = 0 - out = append_html(out, render_block(block, title)) - block = "" - } else { - out = append_html(out, render_block(block, title)) - block = line - in_fence = 1 - } - continue - } - if (in_fence) { - block = append_line(block, line) - continue - } - label = tolower(trim(line)) - if (line ~ /^###[[:space:]]+/ || (title_lower == "behavior" && (label == "happy path:" || label == "edge cases:"))) { - out = append_html(out, render_block(block, title)) - if (title_lower == "behavior" && (label == "happy path:" || label == "edge cases:")) { - sub(/:$/, "", line) - out = append_html(out, "<h3>" render_inline(trim(line)) "</h3>") - } else { - out = append_html(out, render_block(line, title)) - } - block = "" - continue - } - if (trim(line) == "") { - out = append_html(out, render_block(block, title)) - block = "" - } else { - block = append_line(block, line) - } - } - out = append_html(out, render_block(block, title)) - return out -} - -function extract_preamble(text, lines, n, i, line, stripped, h1_idx, subtitle_idx) { - n = split(text, lines, "\n") - doc_h1 = "" - doc_subtitle = "" - doc_intro = "" - h1_idx = 0 - subtitle_idx = 0 - for (i = 1; i <= n; i++) { - line = lines[i] - if (doc_h1 == "" && line ~ /^#[[:space:]]+/) { - doc_h1 = trim(substr(line, 3)) - h1_idx = i - break - } - } - for (i = 1; i <= n; i++) { - if (i == h1_idx) { - continue - } - stripped = trim(lines[i]) - if (stripped == "") { - continue - } - if (stripped ~ /^```/ || stripped ~ /^#/ || stripped ~ /^-/ || stripped ~ /^[*]/ || stripped ~ /^>/ || stripped == "---" || stripped ~ /^[0-9]+[.][[:space:]]+/) { - break - } - doc_subtitle = stripped - subtitle_idx = i - break - } - for (i = 1; i <= n; i++) { - if (i == h1_idx || i == subtitle_idx) { - continue - } - doc_intro = append_line(doc_intro, lines[i]) - } -} - -function meta_line( out) { - out = "" - if (fm_created != "") { - out = "created " fm_created - } - if (fm_pr != "") { - out = out == "" ? "" : out " | " - out = out (fm_pr == "[]" || fm_pr == "null" ? "pr: none" : "pr: " fm_pr) - } - out = out == "" ? "spec" : out " | spec" - return out -} - -function section_is_open(title, idx, lower) { - lower = tolower(title) - return idx < 3 || lower == "problem" || lower == "outcome" || lower == "scope" || lower == "behavior" || lower == "acceptance criteria" -} - -function render_section(title, body, idx, inner, attrs) { - inner = render_blocks(body, title) - attrs = section_is_open(title, idx) ? " class=\"section open\" open" : " class=\"section\"" - return "<details" attrs ">\n <summary><span class=\"chev\">▶</span>" html_escape(title) "</summary>\n <div class=\"body\">" inner "</div>\n</details>" -} - -function print_css() { - print "<style>" - print " :root {" - print " --bg: #ffffff;" - print " --bg-2: #f6f7f9;" - print " --bg-3: #eceef1;" - print " --fg: #1a1d21;" - print " --fg-dim: #4b5563;" - print " --fg-mute: #6b7280;" - print " --accent: #0369a1;" - print " --good: #16a34a;" - print " --border: #e2e5ea;" - print " --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;" - print " --sans: -apple-system, BlinkMacSystemFont, Inter, system-ui, sans-serif;" - print " }" - print " * { box-sizing: border-box; }" - print " html, body { background: var(--bg); color: var(--fg); }" - print " body { font-family: var(--sans); font-size: 15px; line-height: 1.6; margin: 0; }" - print " .wrap { max-width: 920px; margin: 0 auto; padding: 48px 32px 96px; }" - print " header { padding-bottom: 14px; margin-bottom: 18px; }" - print " .meta { font-family: var(--mono); font-size: 12px; color: var(--fg-mute); margin-bottom: 8px; letter-spacing: 0.04em; text-transform: uppercase; }" - print " h1 { font-size: 28px; margin: 0 0 6px; font-weight: 600; letter-spacing: 0; }" - print " .subtitle { color: var(--fg-dim); font-size: 15px; margin: 0; max-width: 760px; }" - print " h3 { font-size: 16px; margin: 18px 0 8px; font-weight: 600; color: var(--fg); }" - print " details.section { border-top: 1px solid var(--border); }" - print " details.section:first-of-type { border-top: 0; }" - print " summary { font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--fg-mute); margin: 0; padding: 18px 0; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 10px; font-weight: 500; list-style: none; }" - print " summary::-webkit-details-marker { display: none; }" - print " summary:hover { color: var(--accent); }" - print " .chev { display: inline-block; transition: transform 0.15s ease; font-family: var(--mono); color: var(--fg-mute); font-size: 11px; }" - print " details[open] .chev { transform: rotate(90deg); }" - print " .body { padding: 0 0 28px 24px; }" - print " p { margin: 0 0 14px; }" - print " a { color: var(--accent); text-decoration: none; }" - print " code { font-family: var(--mono); font-size: 13px; background: var(--bg-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 4px; color: var(--accent); }" - print " pre { background: var(--bg-2); border: 1px solid var(--border); border-radius: 6px; padding: 14px 18px; margin: 0 0 14px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.6; }" - print " pre code { background: transparent; border: 0; padding: 0; color: var(--fg-dim); }" - print " pre.mermaid { color: var(--fg-dim); }" - print " ul, ol { padding-left: 22px; margin: 0 0 14px; }" - print " li { margin-bottom: 6px; }" - print " .ac { list-style: none; padding: 0; margin: 8px 0 16px; }" - print " .ac li { padding: 9px 12px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 4px; font-size: 13px; }" - print " .steps { list-style: decimal; }" - print " hr { border: 0; border-top: 1px solid var(--border); margin: 18px 0; }" - print " footer { margin-top: 48px; padding-top: 18px; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 11px; color: var(--fg-mute); display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }" - print " @media (max-width: 640px) { .wrap { padding: 24px 18px 64px; } h1 { font-size: 22px; } }" - print "</style>" -} - -function print_mermaid_script() { - print "<script type=\"module\">" - print " import mermaid from \"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs\";" - print " mermaid.initialize({ startOnLoad: true, theme: \"default\" });" - print "</script>" -} - -{ - lines[NR] = $0 - if ($0 ~ /^```[[:space:]]*mermaid[[:space:]]*$/) { - has_mermaid = 1 - } -} - -END { - body_start = 1 - if (lines[1] == "---") { - for (i = 2; i <= NR; i++) { - if (lines[i] == "---") { - frontmatter_end = i - break - } - } - if (frontmatter_end > 0) { - for (i = 2; i < frontmatter_end; i++) { - colon = index(lines[i], ":") - if (colon > 0) { - key = trim(substr(lines[i], 1, colon - 1)) - value = trim(substr(lines[i], colon + 1)) - if (key == "created") { - fm_created = value - } else if (key == "pr") { - fm_pr = value - } - } - } - body_start = frontmatter_end + 1 - } - } - - in_fence = 0 - current = 0 - for (i = body_start; i <= NR; i++) { - line = lines[i] - if (line ~ /^```/) { - in_fence = !in_fence - } - if (!in_fence && line ~ /^##[[:space:]]+/) { - current++ - section_title[current] = trim(substr(line, 4)) - section_body[current] = "" - } else if (current > 0) { - section_body[current] = append_line(section_body[current], line) - } else { - preamble = append_line(preamble, line) - } - } - - extract_preamble(preamble) - if (doc_h1 == "") { - doc_h1 = stem - } - sections_html = render_blocks(doc_intro, "") - for (i = 1; i <= current; i++) { - sections_html = append_html(sections_html, render_section(section_title[i], section_body[i], i - 1)) - } - - print "<!DOCTYPE html>" - print "<html lang=\"en\">" - print "<head>" - print "<meta charset=\"UTF-8\">" - print "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">" - print "<title>" html_escape(doc_h1) "" - print_css() - print "" - print "" - print "
" - print "
" - print "
" html_escape(meta_line()) "
" - print "

" html_escape(doc_h1) "

" - if (doc_subtitle != "") { - print "

" render_inline(doc_subtitle) "

" - } - print "
" - print sections_html - print "" - print "
" - if (has_mermaid) { - print_mermaid_script() - } - print "" - print "" -} -' "$src_path" > "$tmp_path" - -mv "$tmp_path" "$out_path" -trap - EXIT -printf '%s\n' "$out_path"