diff --git a/.archon/commands/maintainer-standup.md b/.archon/commands/maintainer-standup.md index 1d02d0c6e7..38fcec49e3 100644 --- a/.archon/commands/maintainer-standup.md +++ b/.archon/commands/maintainer-standup.md @@ -11,6 +11,40 @@ You are producing a daily maintainer briefing for the Archon project. The user i --- +## Output format (read this FIRST, follow exactly) + +Your response must be exactly two parts in order: + +1. **Brief markdown** — starting with the literal line `# Maintainer Standup — YYYY-MM-DD` and continuing through the brief. +2. **State JSON block** — delimited by `ARCHON_STATE_JSON_BEGIN` and `ARCHON_STATE_JSON_END`, each on its own line, with valid JSON between them. + +**Hard rules:** + +- Start the response with the `#` heading. No prose preamble. No "Looking at the data...", no ``, no analysis dump, no "Now I'll synthesize...". +- Do NOT wrap the response in a JSON object. Specifically: do NOT output `{"brief_markdown": "...", "next_state": {...}}` — that is the OLD contract and is wrong. +- Do NOT use markdown code fences around the `ARCHON_STATE_JSON_BEGIN`/`ARCHON_STATE_JSON_END` markers — the markers must be plain lines. +- Nothing after the closing marker. The closing marker is the last line of your response. + +**Skeleton example** (illustrative — your actual brief uses real content): + +``` +# Maintainer Standup — 2026-04-29 + +## Since last run +- ... + +## P1 — Do today +- **PR #N** — ... + +ARCHON_STATE_JSON_BEGIN +{"last_run_at":"2026-04-29T07:00:00Z","last_dev_sha":"abc123","carry_over":[],"observed_prs":[{"number":1,"title":"x"}],"observed_issues":[],"direction_questions":[]} +ARCHON_STATE_JSON_END +``` + +(In your real output the markers and JSON are NOT inside a code fence.) + +--- + ## Phase 1: LOAD INPUTS You have three sources of upstream context, all already gathered. Each is a JSON string that you should parse. @@ -54,7 +88,7 @@ If `prior_state` is `null` and `recent_briefs` is empty, this is a **first run** When `prior_state` exists: - **Resolved since last run**: PRs in `prior_state.observed_prs` whose numbers do NOT appear in current `gh-data.output.all_open_prs` — they were closed or merged. Cross-reference against `gh-data.output.recently_closed_prs` to know whether they merged or were closed without merging. Same for issues. -- **Carry-over revisited**: each item in `prior_state.carry_over` — is it still open? Did its status change? If resolved, mention briefly under "Resolved since last run" and DROP from `next_state.carry_over`. If still pending, keep with original `first_seen` date (so age is preserved). +- **Carry-over revisited**: each item in `prior_state.carry_over` — is it still open? Did its status change? If resolved, mention briefly under "Resolved since last run" and DROP from the state JSON's `carry_over`. If still pending, keep with original `first_seen` date (so age is preserved). - **What you shipped**: `gh-data.output.my_recent_commits` lists the maintainer's commits since the last run. Summarize meaningfully — group by area, highlight notable ones. Don't just list shas. - **New since last run**: PRs in current `all_open_prs` whose numbers are NOT in `prior_state.observed_prs` are new this run. Same for issues. @@ -83,7 +117,7 @@ Issues in `issues_assigned` and `recent_unlabeled_issues` follow the same P1-P4 ### 2f. Surface direction questions -If any PR raises a "we don't have a stance on this" question that `direction.md` doesn't answer, surface it under **Direction questions raised**. These go into `next_state.direction_questions` so the maintainer can absorb them into `direction.md` over time. +If any PR raises a "we don't have a stance on this" question that `direction.md` doesn't answer, surface it under **Direction questions raised**. These go into the state JSON's `direction_questions` so the maintainer can absorb them into `direction.md` over time. ### 2g. Carry-over aging @@ -105,9 +139,11 @@ PRs not in `reviewed_prs` get no marker (their absence is itself the signal: "no ## Phase 3: GENERATE OUTPUT -Return a JSON object matching the workflow's `output_format` schema. Do not write any files yourself — the workflow's `persist` node handles disk writes from your structured response. +Output the brief as plain markdown FIRST, then a state JSON block at the end with EXACT delimiters. The persist node parses your output by splitting on those delimiters — do not return a JSON object wrapping the brief, and do not write any files yourself. + +**No prose preamble.** Start the response with the `# Maintainer Standup` heading. **No content after the closing state marker.** -### `brief_markdown` (string) +### Brief markdown (first) A maintainer-ready markdown brief. Adapt sections — omit empty ones, add others if useful. Keep entries to one line each. The brief should be readable on a single screen. @@ -153,9 +189,30 @@ A maintainer-ready markdown brief. Adapt sections — omit empty ones, add other - (Omit section if nothing carried over.) ``` -### `next_state` (object) +### State JSON block (LAST) -Carry-over state for tomorrow's run. Schema: +Immediately after the brief, emit a state JSON block with these EXACT delimiter lines (each on its own line, no surrounding code fences, no leading/trailing whitespace, no markdown formatting around them): + +``` +ARCHON_STATE_JSON_BEGIN +{ + "last_run_at": "", + "last_dev_sha": "", + "carry_over": [ + { "kind": "pr|issue|task|direction_question", "id": "", "note": "", "first_seen": "" } + ], + "observed_prs": [ + { "number": , "title": "" } + ], + "observed_issues": [ + { "number": <num>, "title": "<title>" } + ], + "direction_questions": ["<surfaced question>"] +} +ARCHON_STATE_JSON_END +``` + +State schema rules: - `last_run_at`: current ISO-8601 timestamp (use the actual timestamp at synthesis time). - `last_dev_sha`: value from `git-status.output.current_dev_sha`. @@ -164,17 +221,23 @@ Carry-over state for tomorrow's run. Schema: - `observed_issues`: same for assigned + unlabeled issues. - `direction_questions`: new direction questions surfaced this run (string array). +The block must be valid JSON between the markers. Use empty arrays `[]` for sections with no entries — do not omit fields. + ### PHASE_3_CHECKPOINT +- [ ] Response starts with the `# Maintainer Standup` heading (no prose preamble). +- [ ] State block uses the exact `ARCHON_STATE_JSON_BEGIN` / `ARCHON_STATE_JSON_END` markers, each on its own line. +- [ ] State block is valid JSON between the markers (no trailing commas, all required fields present). +- [ ] Nothing follows the closing marker. - [ ] Every PR in `all_open_prs` is either classified into P1-P4 OR included in `observed_prs` (no PR silently dropped). - [ ] All P4 entries cite a specific `direction.md §clause`. - [ ] Carry-over items still pending have their original `first_seen` preserved. -- [ ] Resolved-since-last-run items are surfaced in the brief AND removed from `next_state.carry_over`. -- [ ] `next_state.last_dev_sha` is set from `git-status.output.current_dev_sha`. -- [ ] `next_state.observed_prs` includes ALL currently-open PRs. +- [ ] Resolved-since-last-run items are surfaced in the brief AND removed from `state.carry_over`. +- [ ] `state.last_dev_sha` is set from `git-status.output.current_dev_sha`. +- [ ] `state.observed_prs` includes ALL currently-open PRs. --- ## Phase 4: REPORT -Return the JSON object only. The workflow's `persist` node writes `brief_markdown` to `.archon/maintainer-standup/briefs/<date>.md` and `next_state` to `.archon/maintainer-standup/state.json`. Do not write files yourself. +Output the brief markdown then the delimited state block — nothing else. The persist node writes the brief markdown (everything before `ARCHON_STATE_JSON_BEGIN`) to `.archon/maintainer-standup/briefs/<date>.md` and the state JSON (between the markers) to `.archon/maintainer-standup/state.json`. Do not write files yourself. diff --git a/.archon/scripts/maintainer-standup-persist.ts b/.archon/scripts/maintainer-standup-persist.ts new file mode 100644 index 0000000000..44629203ca --- /dev/null +++ b/.archon/scripts/maintainer-standup-persist.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun +/** + * Reads raw synthesize-node output on stdin and writes the brief markdown + + * state.json to .archon/maintainer-standup/. Handles two formats: + * + * Preferred — delimited markers: + * # Maintainer Standup — YYYY-MM-DD + * ...brief... + * ARCHON_STATE_JSON_BEGIN + * {...state json...} + * ARCHON_STATE_JSON_END + * + * Fallback — JSON-wrapped (what Pi/Minimax tends to emit): + * [optional prose preamble] + * {"brief_markdown": "...", "next_state": {...}} + * + * The fallback path is here because Pi/Minimax M2.7 ignores the delimiter + * directive and emits the JSON-wrapper format consistently. JSON.parse can + * still recover it provided the model escaped newlines/quotes correctly. + * + * Output: one line of JSON to stdout: {"date","state_path","brief_path"}. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const raw = await Bun.stdin.text(); + +type State = Record<string, unknown>; +let brief: string | null = null; +let state: State | null = null; +let source: 'delimiter' | 'json-wrapper' | null = null; + +// ── Tier 1: delimiter-based extraction ── +const BEGIN = 'ARCHON_STATE_JSON_BEGIN'; +const END = 'ARCHON_STATE_JSON_END'; +const beginIdx = raw.indexOf(BEGIN); +const endIdx = raw.indexOf(END); +if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) { + const stateText = raw.slice(beginIdx + BEGIN.length, endIdx).trim(); + try { + state = JSON.parse(stateText) as State; + brief = raw.slice(0, beginIdx).trim(); + source = 'delimiter'; + } catch (err) { + process.stderr.write( + `Delimiter found but state JSON parse failed: ${(err as Error).message}\n`, + ); + } +} + +// ── Tier 2: JSON-wrapper fallback ({brief_markdown, next_state}) ── +if (state === null) { + const firstBrace = raw.indexOf('{'); + if (firstBrace !== -1) { + const candidate = raw.slice(firstBrace); + try { + const parsed = JSON.parse(candidate) as Record<string, unknown>; + if ( + typeof parsed.brief_markdown === 'string' && + typeof parsed.next_state === 'object' && + parsed.next_state !== null + ) { + brief = parsed.brief_markdown; + state = parsed.next_state as State; + source = 'json-wrapper'; + process.stderr.write( + 'Synth output used JSON-wrapper format (delimiter contract not followed); recovered via fallback.\n', + ); + } + } catch (err) { + process.stderr.write( + `JSON-wrapper fallback parse failed: ${(err as Error).message}\n`, + ); + } + } +} + +if (state === null || brief === null) { + process.stderr.write( + 'PERSIST FAILED: could not extract brief and state from synth output (neither delimiter nor JSON-wrapper format matched).\n', + ); + process.stderr.write('--- BEGIN raw output (recoverable from logs) ---\n'); + process.stderr.write(raw + '\n'); + process.stderr.write('--- END raw output ---\n'); + process.exit(1); +} + +// Strip leading prose preamble — keep from the first '# ' heading onward. +const lines = brief.split('\n'); +const headingIdx = lines.findIndex((l) => l.startsWith('# ')); +if (headingIdx > 0) { + brief = lines.slice(headingIdx).join('\n'); +} +brief = brief.trim(); + +const date = new Date().toLocaleDateString('sv-SE'); // local YYYY-MM-DD +const baseDir = resolve(process.cwd(), '.archon/maintainer-standup'); +const briefsDir = resolve(baseDir, 'briefs'); +mkdirSync(briefsDir, { recursive: true }); + +const statePath = resolve(baseDir, 'state.json'); +const briefPath = resolve(briefsDir, `${date}.md`); + +writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n'); +writeFileSync(briefPath, brief + '\n'); + +process.stdout.write( + JSON.stringify({ + date, + source, + state_path: '.archon/maintainer-standup/state.json', + brief_path: `.archon/maintainer-standup/briefs/${date}.md`, + }) + '\n', +); diff --git a/.archon/workflows/maintainer/maintainer-standup-minimax.yaml b/.archon/workflows/maintainer/maintainer-standup-minimax.yaml new file mode 100644 index 0000000000..6b1ab26bc4 --- /dev/null +++ b/.archon/workflows/maintainer/maintainer-standup-minimax.yaml @@ -0,0 +1,55 @@ +name: maintainer-standup-minimax +description: | + Minimax variant of maintainer-standup. Identical workflow shape — same + gather scripts, same synthesizer command, same persist node — but the + synthesize node runs on Pi/Minimax M2.7 instead of Claude Sonnet. + Use when: nested-Claude-Code session hangs (#1067) block the Claude variant, + or when you'd rather not spend Claude tokens on the daily brief. + Triggers: "morning standup minimax", "standup minimax", "daily brief minimax". + NOT for: First-time setup — run the Claude variant first to validate state. + +provider: pi +model: minimax/MiniMax-M2.7 + +worktree: + enabled: false # Live checkout — needs to git pull and read .archon/maintainer-standup/ + +nodes: + # ── Layer 0: gather facts in parallel ── + + - id: git-status + script: maintainer-standup-git-status + runtime: bun + timeout: 60000 + + - id: gh-data + script: maintainer-standup-gh-data + runtime: bun + timeout: 180000 + + - id: read-context + script: maintainer-standup-read-context + runtime: bun + timeout: 10000 + + # ── Layer 1: synthesize the brief (plain text + delimited state block) ── + + - id: synthesize + command: maintainer-standup + depends_on: [git-status, gh-data, read-context] + + # ── Layer 2: persist state and dated brief ── + # + # Bash node so the framework shell-quotes $synthesize.output (raw text + # containing markdown code fences and prose isn't a valid JS expression + # if substituted into a bun script body). The bash node pipes the synth + # output into the persist script, which handles both the preferred + # delimiter format and the fallback JSON-wrapper format Pi/Minimax emits. + + - id: persist + depends_on: [synthesize] + timeout: 30000 + bash: | + set -uo pipefail + RAW=$synthesize.output + printf '%s' "$RAW" | bun .archon/scripts/maintainer-standup-persist.ts diff --git a/.archon/workflows/maintainer/maintainer-standup.yaml b/.archon/workflows/maintainer/maintainer-standup.yaml index 9382ce0887..46d5e5723a 100644 --- a/.archon/workflows/maintainer/maintainer-standup.yaml +++ b/.archon/workflows/maintainer/maintainer-standup.yaml @@ -38,125 +38,24 @@ nodes: runtime: bun timeout: 10000 - # ── Layer 1: synthesize the brief ── + # ── Layer 1: synthesize the brief (plain text + delimited state block) ── - id: synthesize command: maintainer-standup depends_on: [git-status, gh-data, read-context] - output_format: - type: object - properties: - brief_markdown: - type: string - description: Human-readable maintainer brief in markdown, with P1-P4 sections. - next_state: - type: object - description: Carry-over state for tomorrow's run. - properties: - last_run_at: - type: string - description: ISO-8601 timestamp of this run. - last_dev_sha: - type: string - description: origin/dev SHA at the end of this run. - carry_over: - type: array - description: Items still pending from previous runs (or surfaced this run). - items: - type: object - properties: - kind: - type: string - enum: [pr, issue, task, direction_question] - id: - type: string - description: PR/issue number as string, or task identifier. - note: - type: string - description: Why this is being carried over. - first_seen: - type: string - description: ISO-8601 date when this item first appeared in carry_over (preserved across runs). - required: [kind, id, note, first_seen] - observed_prs: - type: array - description: Snapshot of ALL currently-open PRs, used to detect resolved/new PRs next run. - items: - type: object - properties: - number: - type: number - title: - type: string - required: [number, title] - observed_issues: - type: array - description: Snapshot of currently-tracked issues (assigned + recent unlabeled). - items: - type: object - properties: - number: - type: number - title: - type: string - required: [number, title] - direction_questions: - type: array - description: New "we don't have a stance on this" questions surfaced this run. - items: - type: string - required: [last_run_at, last_dev_sha, carry_over, observed_prs, observed_issues, direction_questions] - required: [brief_markdown, next_state] # ── Layer 2: persist state and dated brief ── + # + # Bash node so the framework shell-quotes $synthesize.output (raw text + # containing markdown code fences and prose isn't a valid JS expression + # if substituted into a bun script body). The bash node pipes the synth + # output into the persist script, which handles both the preferred + # delimiter format and the fallback JSON-wrapper format Pi/Minimax emits. - id: persist depends_on: [synthesize] - runtime: bun - timeout: 15000 - script: | - import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; - import { resolve } from 'node:path'; - - // JSON is valid JS expression syntax — substitute directly without a - // template literal. Wrapping in String.raw breaks if the output contains - // backticks (e.g. markdown code spans inside brief_markdown). - const data = $synthesize.output; - - // Local YYYY-MM-DD (sv-SE locale gives ISO format in local time) so a - // late-night run doesn't write tomorrow's UTC date and confuse next-run - // recent_briefs lookups. - const date = new Date().toLocaleDateString('sv-SE'); - - try { - const baseDir = resolve(process.cwd(), '.archon/maintainer-standup'); - if (!existsSync(baseDir)) mkdirSync(baseDir, { recursive: true }); - - writeFileSync( - resolve(baseDir, 'state.json'), - JSON.stringify(data.next_state, null, 2) + '\n', - ); - - const briefsDir = resolve(baseDir, 'briefs'); - if (!existsSync(briefsDir)) mkdirSync(briefsDir, { recursive: true }); - const briefPath = resolve(briefsDir, `${date}.md`); - writeFileSync(briefPath, data.brief_markdown); - - console.log(JSON.stringify({ - date, - state_path: '.archon/maintainer-standup/state.json', - brief_path: `.archon/maintainer-standup/briefs/${date}.md`, - })); - } catch (err) { - // Synthesis (Sonnet, ~5 min) is the expensive part. If persist fails - // (disk full, read-only fs, permission), dump the brief + state to - // stderr so the run isn't a total loss — they're recoverable from logs. - process.stderr.write(`PERSIST FAILED: ${err.message}\n`); - process.stderr.write('--- BEGIN brief_markdown (recoverable from logs) ---\n'); - process.stderr.write(data.brief_markdown + '\n'); - process.stderr.write('--- END brief_markdown ---\n'); - process.stderr.write('--- BEGIN next_state (recoverable from logs) ---\n'); - process.stderr.write(JSON.stringify(data.next_state, null, 2) + '\n'); - process.stderr.write('--- END next_state ---\n'); - process.exit(1); - } + timeout: 30000 + bash: | + set -uo pipefail + RAW=$synthesize.output + printf '%s' "$RAW" | bun .archon/scripts/maintainer-standup-persist.ts