Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 73 additions & 10 deletions .archon/commands/maintainer-standup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<thinking>`, 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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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": "<ISO-8601 timestamp>",
"last_dev_sha": "<git-status.output.current_dev_sha>",
"carry_over": [
{ "kind": "pr|issue|task|direction_question", "id": "<PR/issue number as string>", "note": "<why carried>", "first_seen": "<YYYY-MM-DD>" }
],
"observed_prs": [
{ "number": <num>, "title": "<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`.
Expand All @@ -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.
114 changes: 114 additions & 0 deletions .archon/scripts/maintainer-standup-persist.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +28 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate required state schema before persisting.

Line 41 and Line 64 accept any parsed JSON as state, and Line 104 writes it directly. If required fields are missing or wrong types, next runs can silently degrade or fail during progress detection.

Proposed guardrail for state shape validation
-type State = Record<string, unknown>;
+type CarryOverItem = { kind: string; id: string; note: string; first_seen: string };
+type ObservedItem = { number: number; title: string };
+type State = {
+  last_run_at: string;
+  last_dev_sha: string;
+  carry_over: CarryOverItem[];
+  observed_prs: ObservedItem[];
+  observed_issues: ObservedItem[];
+  direction_questions: string[];
+};
+
+const isObjectRecord = (v: unknown): v is Record<string, unknown> =>
+  typeof v === 'object' && v !== null && !Array.isArray(v);
+
+function isState(v: unknown): v is State {
+  if (!isObjectRecord(v)) return false;
+  return (
+    typeof v.last_run_at === 'string' &&
+    typeof v.last_dev_sha === 'string' &&
+    Array.isArray(v.carry_over) &&
+    Array.isArray(v.observed_prs) &&
+    Array.isArray(v.observed_issues) &&
+    Array.isArray(v.direction_questions)
+  );
+}
@@
-    state = JSON.parse(stateText) as State;
+    const parsedState = JSON.parse(stateText);
+    if (!isState(parsedState)) {
+      throw new Error('Delimiter state JSON does not match required schema');
+    }
+    state = parsedState;
@@
-      const parsed = JSON.parse(candidate) as Record<string, unknown>;
+      const parsed = JSON.parse(candidate) as Record<string, unknown>;
       if (
         typeof parsed.brief_markdown === 'string' &&
-        typeof parsed.next_state === 'object' &&
-        parsed.next_state !== null
+        isState(parsed.next_state)
       ) {
         brief = parsed.brief_markdown;
-        state = parsed.next_state as State;
+        state = parsed.next_state;

Also applies to: 38-42, 57-65, 104-104

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.archon/scripts/maintainer-standup-persist.ts around lines 28 - 31, The
parsed JSON assigned to the module-level variables (brief, state, source) is not
schema-validated before being used or persisted; add a runtime validation step
(either a lightweight type-guard function or a schema using zod/io-ts) that
checks required keys and types for the expected state shape, ensures brief is a
non-empty string and source is one of 'delimiter'|'json-wrapper', and call this
validator wherever parsed data is assigned to state (the places that currently
set state from JSON) and immediately before the persist/write routine that saves
state; if validation fails, log a clear error via the existing logger and skip
persisting (or recover to a safe default) so malformed data is never written
back.


// ── 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();
Comment on lines +34 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Anchor closing-marker lookup to the opening marker.

Line 37 searches ARCHON_STATE_JSON_END from the start of the payload. If marker-like text appears before the real block, valid delimiter output can be misread.

Suggested delimiter extraction hardening
 const BEGIN = 'ARCHON_STATE_JSON_BEGIN';
 const END = 'ARCHON_STATE_JSON_END';
 const beginIdx = raw.indexOf(BEGIN);
-const endIdx = raw.indexOf(END);
+const endIdx = beginIdx === -1 ? -1 : raw.indexOf(END, beginIdx + BEGIN.length);
 if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.archon/scripts/maintainer-standup-persist.ts around lines 34 - 39, The
end-marker lookup currently uses raw.indexOf(END) which can find an earlier END
before the real BEGIN; change it to search for END starting after the found
BEGIN (use the variant of indexOf that accepts a start position) so endIdx =
raw.indexOf(END, beginIdx + BEGIN.length) and keep the existing checks (beginIdx
!== -1 && endIdx !== -1 && endIdx > beginIdx) before slicing stateText from
raw.slice(beginIdx + BEGIN.length, endIdx). This ensures the END anchor is
anchored to the found BEGIN and prevents mistaking earlier marker-like text.

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',
);
55 changes: 55 additions & 0 deletions .archon/workflows/maintainer/maintainer-standup-minimax.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading