@serranolabs.io/munchkins 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { Prompt } from "@serranolabs.io/munchkins-core";
3
+ import { type OptionSchema, Prompt } from "@serranolabs.io/munchkins-core";
4
4
 
5
5
  export function getAgentPromptsDir(importUrl: string): string {
6
6
  return join(dirname(fileURLToPath(importUrl)), "prompts");
@@ -22,6 +22,14 @@ export const DEFAULT_CHECKS: readonly string[] = [
22
22
  "bun test --pass-with-no-tests",
23
23
  ];
24
24
 
25
+ // Shared --branch-prefix declaration for the default agents. Director dispatch
26
+ // passes this through to child runs to scope their branches under `director/*`.
27
+ export const BRANCH_PREFIX_OPTION: OptionSchema = {
28
+ type: "string",
29
+ required: false,
30
+ description: "Branch namespace prefix; defaults to 'agent'",
31
+ };
32
+
25
33
  export function defaultFixer(): Prompt {
26
34
  return new Prompt(DETERMINISTIC_FIXER_PATH);
27
35
  }
@@ -1,24 +1,22 @@
1
- import { join } from "node:path";
2
1
  import { AgentBuilder, gitWorktreeSandbox, Prompt, registry } from "@serranolabs.io/munchkins-core";
3
2
  import {
3
+ BRANCH_PREFIX_OPTION,
4
4
  DEFAULT_CHECKS,
5
5
  defaultFixer,
6
6
  defaultSummaryWriter,
7
7
  GUIDELINES_PATH,
8
- getAgentPromptsDir,
9
8
  REFACTORER_PATH,
10
9
  } from "../_shared/presets.js";
11
10
 
12
- const PROMPTS = getAgentPromptsDir(import.meta.url);
13
-
14
11
  const builder = new AgentBuilder(
15
12
  "bug-fix",
16
13
  "Fix a bug described in a markdown user-message file.",
17
14
  gitWorktreeSandbox(),
18
15
  )
16
+ .option("branchPrefix", BRANCH_PREFIX_OPTION)
19
17
  .add(
20
18
  new Prompt(GUIDELINES_PATH)
21
- .withSystem(join(PROMPTS, "bug-fix.md"))
19
+ .withSkill("munchkins:bug-fix")
22
20
  .withUserMessageFromOption("userMessage", {
23
21
  required: true,
24
22
  description: "Path to a markdown file describing the bug",
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { registry } from "@serranolabs.io/munchkins-core";
7
+ import { $ } from "bun";
8
+ import "./director-agent.js";
9
+
10
+ const HERE = dirname(fileURLToPath(import.meta.url));
11
+ const REPO_ROOT = resolve(HERE, "../../../..");
12
+ const SCRIPTS = join(HERE, "scripts");
13
+
14
+ const TEST_GIT_IDENTITY = {
15
+ GIT_AUTHOR_NAME: "t",
16
+ GIT_AUTHOR_EMAIL: "t@t",
17
+ GIT_COMMITTER_NAME: "t",
18
+ GIT_COMMITTER_EMAIL: "t@t",
19
+ };
20
+
21
+ interface Repo {
22
+ path: string;
23
+ cleanup: () => void;
24
+ }
25
+
26
+ async function createBareRepo(): Promise<Repo> {
27
+ const path = mkdtempSync(join(tmpdir(), "munchkins-director-test-"));
28
+ const env = { ...process.env, ...TEST_GIT_IDENTITY };
29
+ await $`git init -b main`.cwd(path).env(env).quiet();
30
+ await $`git config user.email t@t`.cwd(path).env(env).quiet();
31
+ await $`git config user.name t`.cwd(path).env(env).quiet();
32
+ await Bun.write(join(path, "seed.ts"), "export const seed = 1;\n");
33
+ await $`git add -A`.cwd(path).env(env).quiet();
34
+ await $`git commit -m seed`.cwd(path).env(env).quiet();
35
+ return {
36
+ path,
37
+ cleanup: () => {
38
+ try {
39
+ rmSync(path, { recursive: true, force: true });
40
+ } catch {
41
+ // best-effort
42
+ }
43
+ },
44
+ };
45
+ }
46
+
47
+ describe("director registration", () => {
48
+ test("director is registered under name 'director' after side-effect import", () => {
49
+ expect(registry.get("director")).toBeDefined();
50
+ expect(registry.get("director")?.name).toBe("director");
51
+ });
52
+
53
+ test("cron config is every 10 minutes, thinking verbosity, userMessage 'tick'", () => {
54
+ const builder = registry.get("director");
55
+ expect(builder?.getCron()).toEqual({
56
+ spec: "*/10 * * * *",
57
+ userMessage: "tick",
58
+ verbosity: "thinking",
59
+ });
60
+ });
61
+
62
+ test("step count is 7: 3 deterministic + 3 agent + 1 trailing post-checks", () => {
63
+ // The director's main pipeline is 6 steps (steps 1–6); the trailing
64
+ // .addDeterministic(DEFAULT_CHECKS, defaultFixer) is the 7th, kept for
65
+ // parity with other munchkins so the integration phase has a gate to run.
66
+ expect(registry.get("director")?.getStepCount()).toBe(7);
67
+ });
68
+
69
+ test("director opts out of the framework's --dry-run short-circuit", () => {
70
+ expect(registry.get("director")?.getHandlesDryRun()).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("director scripts", () => {
75
+ let repo: Repo;
76
+
77
+ beforeEach(async () => {
78
+ repo = await createBareRepo();
79
+ // The scripts read $WORKTREE and $REPO_ROOT from the env; for these
80
+ // tests both point at the same temp repo (one-checkout fixture).
81
+ });
82
+
83
+ afterEach(() => {
84
+ repo.cleanup();
85
+ });
86
+
87
+ test("repo-survey.sh exits non-zero with the expected error when PURPOSE.md is absent", async () => {
88
+ const env = {
89
+ ...process.env,
90
+ ...TEST_GIT_IDENTITY,
91
+ WORKTREE: repo.path,
92
+ REPO_ROOT: repo.path,
93
+ PATH: process.env.PATH ?? "",
94
+ };
95
+ // Seed the .director/current sentinel; the script checks PURPOSE.md
96
+ // before reading it, so the failure path doesn't depend on this — but
97
+ // having it present rules out "missing sentinel" as the failure mode.
98
+ await $`mkdir -p ${repo.path}/.director`.quiet();
99
+ await Bun.write(join(repo.path, ".director", "current"), "fake-run");
100
+ const r = await $`bash ${join(SCRIPTS, "repo-survey.sh")}`.env(env).nothrow().quiet();
101
+ expect(r.exitCode).not.toBe(0);
102
+ const combined = r.stdout.toString() + r.stderr.toString();
103
+ expect(combined).toContain("PURPOSE.md not found at repo root");
104
+ expect(combined).toContain("docs/pages/agents/director.md");
105
+ });
106
+
107
+ test("inflight-survey.sh in a repo with no director/* branches writes inflight.json with empty branches and worktrees", async () => {
108
+ // Stub `gh` to fail so the PR inventory falls back to '[]'.
109
+ const stubDir = mkdtempSync(join(tmpdir(), "munchkins-director-stub-"));
110
+ try {
111
+ await Bun.write(join(stubDir, "gh"), "#!/usr/bin/env bash\nexit 1\n");
112
+ await $`chmod +x ${join(stubDir, "gh")}`.quiet();
113
+
114
+ const env = {
115
+ ...process.env,
116
+ ...TEST_GIT_IDENTITY,
117
+ WORKTREE: repo.path,
118
+ REPO_ROOT: repo.path,
119
+ PATH: `${stubDir}:${process.env.PATH ?? ""}`,
120
+ };
121
+ const r = await $`bash ${join(SCRIPTS, "inflight-survey.sh")}`.env(env).nothrow().quiet();
122
+ expect(r.exitCode).toBe(0);
123
+
124
+ const runId = (await Bun.file(join(repo.path, ".director", "current")).text()).trim();
125
+ expect(runId).toMatch(/^\d{8}T\d{6}-/);
126
+
127
+ const inflightPath = join(repo.path, ".director", runId, "inflight.json");
128
+ const inflight = JSON.parse(await Bun.file(inflightPath).text());
129
+ expect(inflight.branches).toEqual([]);
130
+ expect(inflight.worktrees).toEqual([]);
131
+ // gh stub fails, so prs falls back to '[]' (empty array).
132
+ expect(inflight.prs).toEqual([]);
133
+ } finally {
134
+ rmSync(stubDir, { recursive: true, force: true });
135
+ }
136
+ });
137
+ });
138
+
139
+ // Smoke check that the director skill symlink resolves to the package source.
140
+ // Important because every agent step loads the skill via withSkill("director")
141
+ // and a stale symlink would surface as a runtime "skill not found".
142
+ describe("director skill availability", () => {
143
+ test(".claude/skills/director points at packages/munchkins/skills/director", async () => {
144
+ const skillPath = join(REPO_ROOT, ".claude/skills/director/SKILL.md");
145
+ const content = await Bun.file(skillPath).text();
146
+ expect(content).toContain("name: director");
147
+ expect(content).toContain("Vertical-slice rule");
148
+ });
149
+ });
@@ -0,0 +1,45 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { AgentBuilder, gitWorktreeSandbox, Prompt, registry } from "@serranolabs.io/munchkins-core";
4
+ import {
5
+ DEFAULT_CHECKS,
6
+ defaultFixer,
7
+ defaultSummaryWriter,
8
+ GUIDELINES_PATH,
9
+ } from "../_shared/presets.js";
10
+
11
+ const AGENT_DIR = dirname(fileURLToPath(import.meta.url));
12
+ const PROMPTS = join(AGENT_DIR, "prompts");
13
+ const SCRIPTS = join(AGENT_DIR, "scripts");
14
+
15
+ const builder = new AgentBuilder(
16
+ "director",
17
+ "Cron-driven orchestrator that triages, plans, and dispatches work via other munchkins.",
18
+ gitWorktreeSandbox(),
19
+ )
20
+ // userMessage is the cron tick payload; the daemon also passes it via env.
21
+ // No prompt step reads it directly — the agent's source of truth is
22
+ // PURPOSE.md plus the in-flight survey, not a per-tick user message.
23
+ .option("userMessage", {
24
+ type: "string",
25
+ required: false,
26
+ description:
27
+ "Per-tick payload (default 'tick'); unused by the pipeline but required by the CLI surface.",
28
+ default: "tick",
29
+ })
30
+ .addDeterministic([`bash ${join(SCRIPTS, "inflight-survey.sh")}`])
31
+ .addDeterministic([`bash ${join(SCRIPTS, "repo-survey.sh")}`])
32
+ .add(new Prompt(GUIDELINES_PATH).withSkill("director").withSystem(join(PROMPTS, "triage.md")))
33
+ .add(new Prompt(GUIDELINES_PATH).withSkill("director").withSystem(join(PROMPTS, "spec.md")))
34
+ .add(new Prompt(GUIDELINES_PATH).withSkill("director").withSystem(join(PROMPTS, "plan.md")))
35
+ .addDeterministic([`bash ${join(SCRIPTS, "dispatch.sh")}`])
36
+ .addDeterministic([...DEFAULT_CHECKS], {
37
+ loop: { maxIterations: 3, fixer: defaultFixer() },
38
+ })
39
+ .summaryWriter(defaultSummaryWriter())
40
+ .handlesDryRun()
41
+ .cron("*/10 * * * *", { userMessage: "tick", verbosity: "thinking" });
42
+
43
+ registry.register(builder);
44
+
45
+ export { builder };
@@ -0,0 +1,81 @@
1
+ # Director step 5 — Plan
2
+
3
+ You are the **Plan** step of the director pipeline. Pipeline position: step 5 of 6.
4
+
5
+ ## Early-exit on upstream idle
6
+
7
+ Read `.director/<run>/spec.md` first (discover `<run>` via `.director/current`). If the file's first non-empty line contains `"idle": true`, write the following to `.director/<run>/plan.md` and exit immediately:
8
+
9
+ ```
10
+ {"idle": true, "reason": "upstream idle"}
11
+ ```
12
+
13
+ Do not run any other step.
14
+
15
+ ## Input artifacts
16
+
17
+ - `.director/<run>/spec.md` — the thinnest viable slice from the Spec step.
18
+
19
+ ## What to do
20
+
21
+ Two passes, in the same conversation:
22
+
23
+ ### Pass A — Design tree
24
+
25
+ Read the spec. Enumerate every concrete decision point or ambiguity a downstream implementer would have to resolve. Examples:
26
+
27
+ - "Which file owns the new helper?"
28
+ - "Sync or async API shape?"
29
+ - "New test file or extend an existing one?"
30
+ - "Does this need a feature flag?"
31
+
32
+ Be exhaustive. If a decision is genuinely trivial, name it and mark it `trivial`. If it's load-bearing, name it and leave it open.
33
+
34
+ ### Pass B — Architect resolution
35
+
36
+ For each non-trivial item in Pass A, make an opinionated call. Cite the reason in one sentence. Apply these tiebreakers in order:
37
+
38
+ 1. **Match the repo's existing patterns.** Read the relevant code; copy the shape that's already there.
39
+ 2. **Smaller change wins.** Prefer extension over new modules; prefer composition over abstraction.
40
+ 3. **Reversibility.** When two options have similar surface, pick the one easier to undo.
41
+
42
+ If after one pass an ambiguity remains genuinely unresolvable (the spec is too vague, both options are equally load-bearing, the architect doesn't have enough context), **idle the tick**. Don't ship a half-resolved plan.
43
+
44
+ ## Output
45
+
46
+ Write exactly one file: `.director/<run>/plan.md`.
47
+
48
+ Non-idle format — a user-message-ready markdown brief that the downstream munchkin can consume directly:
49
+
50
+ ```markdown
51
+ # <slice title from spec.md>
52
+
53
+ ## Goal
54
+ <one paragraph>
55
+
56
+ ## Files to modify
57
+ - `<path>` — <what changes>
58
+ - ...
59
+
60
+ ## Implementation plan
61
+ <numbered steps; each step names files and the resolved design decision behind it>
62
+
63
+ ## Acceptance criteria
64
+ - <runnable checks>
65
+ - ...
66
+
67
+ ## Out of scope
68
+ - <explicit non-changes>
69
+ ```
70
+
71
+ Idle format:
72
+
73
+ ```json
74
+ {"idle": true, "reason": "<short — e.g. 'architect could not resolve sync-vs-async ambiguity'>"}
75
+ ```
76
+
77
+ ## Rules
78
+
79
+ - Touch no other files. Your only side effect is writing `plan.md`.
80
+ - The non-idle plan must be self-contained — the downstream munchkin will receive it as `--user-message` and won't have context for anything you leave implicit.
81
+ - Idle is valid. Better to idle than to ship a half-resolved plan.
@@ -0,0 +1,65 @@
1
+ # Director step 4 — Spec
2
+
3
+ You are the **Spec** step of the director pipeline. Pipeline position: step 4 of 6.
4
+
5
+ ## Early-exit on upstream idle
6
+
7
+ Read `.director/<run>/triage.json` first (discover `<run>` via `.director/current`). If it contains `"idle": true`, write the following to `.director/<run>/spec.md` and exit immediately:
8
+
9
+ ```
10
+ {"idle": true, "reason": "upstream idle"}
11
+ ```
12
+
13
+ Do not run any other step.
14
+
15
+ ## Input artifacts
16
+
17
+ - `.director/<run>/triage.json` — non-idle: `{ work_type, justification, independence_argument, goal }`.
18
+ - `PURPOSE.md` — for tone-matching the slice against the repo's success criteria.
19
+
20
+ ## What to do
21
+
22
+ Two passes, in the same conversation:
23
+
24
+ ### Pass A — Ambitious draft
25
+
26
+ Produce the most ambitious version of the slice that is still consistent with the `work_type`. Name the files, the new public surfaces, the user-visible behavior, the acceptance criteria. Don't trim yet — this pass exists so the next pass has something concrete to cut.
27
+
28
+ ### Pass B — Less-is-more cut
29
+
30
+ Re-read Pass A. Cut everything that isn't load-bearing for the slice's stated `goal`. Apply ruthlessly:
31
+
32
+ - Remove "nice to have" sub-features.
33
+ - Collapse multiple acceptance criteria into one if they test the same thing.
34
+ - Strip any "we should also" expansions — they belong in a future tick.
35
+ - Prefer extending existing files over creating new ones.
36
+
37
+ The result is the **thinnest viable slice**.
38
+
39
+ ## Output
40
+
41
+ Write exactly one file: `.director/<run>/spec.md`. It must contain only the post-cut spec — *not* Pass A. Structure:
42
+
43
+ ```markdown
44
+ # <short slice title>
45
+
46
+ ## Goal
47
+ <one paragraph from triage.json, refined>
48
+
49
+ ## Files in scope
50
+ - `<path>` — <one-line reason>
51
+ - ...
52
+
53
+ ## Acceptance criteria
54
+ - <observable, runnable check>
55
+ - ...
56
+
57
+ ## Out of scope
58
+ - <explicit cuts from Pass A>
59
+ ```
60
+
61
+ ## Rules
62
+
63
+ - Touch no other files. Your only side effect is writing `spec.md`.
64
+ - The spec must be small enough that the downstream munchkin (`feat-small` / `bug-fix` / `refactor`) can ship it in one run without expanding scope.
65
+ - If during Pass B you realize the slice is actually two slices, keep one and put the other in "Out of scope".
@@ -0,0 +1,49 @@
1
+ # Director step 3 — Triage
2
+
3
+ You are the **Triage** step of the director pipeline. Pipeline position: step 3 of 6.
4
+
5
+ ## Input artifacts (in the worktree)
6
+
7
+ - `PURPOSE.md` — the repo's north star. Source of truth for what counts as "advancing the project".
8
+ - `.director/<run>/inflight.json` — array of in-flight `director/*` work (PRs, branches, worktrees) with file scope and goal. May be `[]`.
9
+ - `.director/<run>/survey.md` — repo state: recent `git log`, open PRs (all), lint/typecheck status.
10
+
11
+ Discover the current run directory by reading `.director/current` from the worktree root.
12
+
13
+ ## What to do
14
+
15
+ 1. Read all three input artifacts.
16
+ 2. Brainstorm candidate slices that would advance an unmet `PURPOSE.md` success criterion. Be ambitious about *what* matters; be specific about file scope.
17
+ 3. For each candidate, apply the **vertical-slice rule** (disjoint file scope / no upstream dep / no downstream coupling) against every entry in `inflight.json`.
18
+ 4. Among candidates that pass the rule, apply the **"less is more" tiebreakers** (cheaper work type wins; fewer files / fewer concepts wins; don't refactor newly shipped features).
19
+ 5. Pick exactly one candidate. If no candidate qualifies, idle.
20
+
21
+ ## Output
22
+
23
+ Write exactly one file: `.director/<run>/triage.json`.
24
+
25
+ Schema (non-idle):
26
+
27
+ ```json
28
+ {
29
+ "work_type": "feature" | "bug-fix" | "refactor" | "performance",
30
+ "justification": "<2-4 sentences: why this slice now, citing which PURPOSE.md bullet it advances>",
31
+ "independence_argument": "<for each inflight entry, a sentence explaining why the three vertical-slice criteria pass>",
32
+ "goal": "<one paragraph describing the slice — what changes, in which files, observable outcome>"
33
+ }
34
+ ```
35
+
36
+ Schema (idle):
37
+
38
+ ```json
39
+ {
40
+ "idle": true,
41
+ "reason": "<short — e.g. 'all candidates depend on PR #42' or 'PURPOSE.md success criteria all satisfied'>"
42
+ }
43
+ ```
44
+
45
+ ## Rules
46
+
47
+ - Touch no other files. Do **not** modify code, commit, or open PRs. Your only side effect is writing `triage.json`.
48
+ - The `goal` field must be specific enough that the Spec step (next) can turn it into a thinnest-viable-slice without re-deriving intent.
49
+ - Idle is valid. Better to idle than to ship a slice that violates the vertical-slice rule.
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # Step 6 — Dispatch. Reads triage.json + plan.md, derives the target munchkin
3
+ # from work_type, then runs the child as a foreground subprocess. Early-exits
4
+ # on upstream idle and short-circuits to a print-only path when
5
+ # $__MUNCHKINS_OPT_dryRun is "true".
6
+
7
+ set -euo pipefail
8
+
9
+ WORKDIR="${WORKTREE:-$PWD}"
10
+ REPO="${REPO_ROOT:-$WORKDIR}"
11
+
12
+ RUN_ID="$(cat "$WORKDIR/.director/current" 2>/dev/null || true)"
13
+ if [ -z "$RUN_ID" ]; then
14
+ echo "[director] dispatch: .director/current missing — pipeline did not initialize" >&2
15
+ exit 1
16
+ fi
17
+
18
+ RUN_DIR="$WORKDIR/.director/$RUN_ID"
19
+ TRIAGE="$RUN_DIR/triage.json"
20
+ PLAN="$RUN_DIR/plan.md"
21
+
22
+ # Upstream-idle guard: if any upstream artifact says idle, exit 0.
23
+ if [ -f "$TRIAGE" ] && grep -qE '"idle"[[:space:]]*:[[:space:]]*true' "$TRIAGE"; then
24
+ echo "[director] dispatch: triage idle — nothing to do"
25
+ exit 0
26
+ fi
27
+ if [ -f "$PLAN" ] && head -n 5 "$PLAN" | grep -qE '"idle"[[:space:]]*:[[:space:]]*true'; then
28
+ echo "[director] dispatch: plan idle — nothing to do"
29
+ exit 0
30
+ fi
31
+
32
+ if [ ! -f "$TRIAGE" ] || [ ! -f "$PLAN" ]; then
33
+ echo "[director] dispatch: missing triage.json or plan.md in $RUN_DIR" >&2
34
+ exit 1
35
+ fi
36
+
37
+ # Extract work_type. Use a Bun one-liner so we don't ship a shell JSON parser.
38
+ work_type="$(bun -e '
39
+ const t = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf-8"));
40
+ process.stdout.write(String(t.work_type ?? ""));
41
+ ' "$TRIAGE")"
42
+
43
+ case "$work_type" in
44
+ feature) target="feat-small" ;;
45
+ bug-fix) target="bug-fix" ;;
46
+ refactor) target="refactor" ;;
47
+ performance) target="refactor" ;; # Phase 1 mapping; replaced by `performance` in Phase 2.
48
+ *)
49
+ echo "[director] dispatch: unknown work_type \"$work_type\" in $TRIAGE" >&2
50
+ exit 1
51
+ ;;
52
+ esac
53
+
54
+ cmd=(bun run munchkins "$target" "--user-message=$PLAN" "--branch-prefix=director")
55
+
56
+ if [ "${__MUNCHKINS_OPT_dryRun:-}" = "true" ]; then
57
+ echo "[director] dispatch (dry-run): ${cmd[*]}"
58
+ exit 0
59
+ fi
60
+
61
+ echo "[director] dispatch: ${cmd[*]}"
62
+ cd "$REPO"
63
+ exec "${cmd[@]}"
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # Step 1 — Inflight-survey. Inventories all director-spawned work currently
3
+ # in flight: open director/* PRs (when `gh` is available + authed), local
4
+ # director/* branches, and director-namespaced worktrees.
5
+
6
+ set -euo pipefail
7
+
8
+ WORKDIR="${WORKTREE:-$PWD}"
9
+ DIRECTOR_ROOT="$WORKDIR/.director"
10
+
11
+ # Derive a stable, sortable run id and remember it for downstream steps.
12
+ RUN_ID="$(date -u +%Y%m%dT%H%M%S)-${RANDOM}${RANDOM}"
13
+ RUN_DIR="$DIRECTOR_ROOT/$RUN_ID"
14
+ mkdir -p "$RUN_DIR"
15
+ printf '%s' "$RUN_ID" > "$DIRECTOR_ROOT/current"
16
+
17
+ OUT="$RUN_DIR/inflight.json"
18
+
19
+ # Branch inventory (always available).
20
+ branches=()
21
+ while IFS= read -r br; do
22
+ br="${br#"${br%%[![:space:]]*}"}"
23
+ [ -n "$br" ] && branches+=("$br")
24
+ done < <(git -C "$WORKDIR" branch --list 'director/*' --format='%(refname:short)' 2>/dev/null || true)
25
+
26
+ # Worktree inventory (filter porcelain output for director-namespaced branches).
27
+ worktrees=()
28
+ if git -C "$WORKDIR" worktree list --porcelain >/dev/null 2>&1; then
29
+ current_path=""
30
+ while IFS= read -r line; do
31
+ case "$line" in
32
+ "worktree "*) current_path="${line#worktree }" ;;
33
+ "branch refs/heads/director/"*)
34
+ wbranch="${line#branch refs/heads/}"
35
+ worktrees+=("$current_path|$wbranch")
36
+ ;;
37
+ "")
38
+ current_path=""
39
+ ;;
40
+ esac
41
+ done < <(git -C "$WORKDIR" worktree list --porcelain)
42
+ fi
43
+
44
+ # Open PRs against director/* heads. Best-effort: skip silently if `gh` is
45
+ # missing or unauthenticated — the local inventory is still useful.
46
+ pr_json="[]"
47
+ if command -v gh >/dev/null 2>&1; then
48
+ if pr_json_raw="$(gh pr list --head 'director/*' --state open \
49
+ --json number,title,headRefName,files 2>/dev/null)"; then
50
+ pr_json="$pr_json_raw"
51
+ fi
52
+ fi
53
+
54
+ # Compose final JSON. Branches and worktrees are flat string arrays; PRs is
55
+ # whatever `gh` emitted (already JSON). Idle case is the empty triplet — the
56
+ # triage step treats it as "nothing in flight".
57
+ {
58
+ printf '{\n'
59
+ printf ' "branches": ['
60
+ if [ "${#branches[@]}" -gt 0 ]; then
61
+ sep=""
62
+ for b in "${branches[@]}"; do
63
+ esc="${b//\\/\\\\}"
64
+ esc="${esc//\"/\\\"}"
65
+ printf '%s"%s"' "$sep" "$esc"
66
+ sep=", "
67
+ done
68
+ fi
69
+ printf '],\n'
70
+ printf ' "worktrees": ['
71
+ if [ "${#worktrees[@]}" -gt 0 ]; then
72
+ sep=""
73
+ for w in "${worktrees[@]}"; do
74
+ p="${w%%|*}"
75
+ bv="${w##*|}"
76
+ printf '%s{"path": "%s", "branch": "%s"}' "$sep" "${p//\"/\\\"}" "${bv//\"/\\\"}"
77
+ sep=", "
78
+ done
79
+ fi
80
+ printf '],\n'
81
+ printf ' "prs": %s\n' "$pr_json"
82
+ printf '}\n'
83
+ } > "$OUT"
84
+
85
+ echo "[director] inflight-survey wrote $OUT (run-id=$RUN_ID)"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # Step 2 — Repo-survey. Captures `git log`, `gh pr list`, lint/typecheck status
3
+ # into a markdown brief the triage step reads alongside PURPOSE.md.
4
+ #
5
+ # Gates the rest of the pipeline on PURPOSE.md being present at the repo root.
6
+
7
+ set -euo pipefail
8
+
9
+ WORKDIR="${WORKTREE:-$PWD}"
10
+ REPO="${REPO_ROOT:-$WORKDIR}"
11
+
12
+ if [ ! -f "$REPO/PURPOSE.md" ]; then
13
+ echo "PURPOSE.md not found at repo root. The director requires a written north star. See docs/pages/agents/director.md." >&2
14
+ exit 1
15
+ fi
16
+
17
+ RUN_ID="$(cat "$WORKDIR/.director/current" 2>/dev/null || true)"
18
+ if [ -z "$RUN_ID" ]; then
19
+ echo "[director] repo-survey: .director/current missing — did inflight-survey run?" >&2
20
+ exit 1
21
+ fi
22
+
23
+ RUN_DIR="$WORKDIR/.director/$RUN_ID"
24
+ mkdir -p "$RUN_DIR"
25
+ OUT="$RUN_DIR/survey.md"
26
+
27
+ {
28
+ echo "# Director repo survey — $(date -u +%Y-%m-%dT%H:%M:%SZ)"
29
+ echo
30
+ echo "## Recent commits (\`git log --oneline -30\`)"
31
+ echo
32
+ echo '```'
33
+ git -C "$REPO" log --oneline -30 2>&1 || true
34
+ echo '```'
35
+ echo
36
+ echo "## Open PRs (\`gh pr list\`)"
37
+ echo
38
+ if command -v gh >/dev/null 2>&1; then
39
+ echo '```'
40
+ gh pr list --limit 30 2>&1 || echo "(gh pr list failed — likely unauthenticated)"
41
+ echo '```'
42
+ else
43
+ echo "(\`gh\` not on PATH — open PRs unknown)"
44
+ fi
45
+ echo
46
+ echo "## Lint status (\`bun run lint\`)"
47
+ echo
48
+ if (cd "$REPO" && bun run lint >/dev/null 2>&1); then
49
+ echo "PASS"
50
+ else
51
+ echo "FAIL"
52
+ fi
53
+ echo
54
+ echo "## Typecheck status (\`bun run typecheck\`)"
55
+ echo
56
+ if (cd "$REPO" && bun run typecheck >/dev/null 2>&1); then
57
+ echo "PASS"
58
+ else
59
+ echo "FAIL"
60
+ fi
61
+ } > "$OUT"
62
+
63
+ echo "[director] repo-survey wrote $OUT"
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { AgentBuilder, gitWorktreeSandbox, Prompt, registry } from "@serranolabs.io/munchkins-core";
3
3
  import {
4
+ BRANCH_PREFIX_OPTION,
4
5
  DEFAULT_CHECKS,
5
6
  defaultFixer,
6
7
  GUIDELINES_PATH,
@@ -16,9 +17,10 @@ const builder = new AgentBuilder(
16
17
  "Implement a new feature described in a markdown user-message file.",
17
18
  gitWorktreeSandbox(),
18
19
  )
20
+ .option("branchPrefix", BRANCH_PREFIX_OPTION)
19
21
  .add(
20
22
  new Prompt(GUIDELINES_PATH)
21
- .withSystem(join(PROMPTS, "feat-small.md"))
23
+ .withSkill("munchkins:feat-small")
22
24
  .withUserMessageFromOption("userMessage", {
23
25
  required: true,
24
26
  description: "Path to a markdown file (or inline text) describing the feature",