sandcastle-drain 0.1.0

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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +139 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/content/agent-docs/issue-tracker.md +22 -0
  8. package/dist/content/agent-docs/sandcastle-windows-cleanup.md +45 -0
  9. package/dist/content/agent-docs/triage-labels.md +101 -0
  10. package/dist/content/principles/README.md +39 -0
  11. package/dist/content/principles/architecture.md +124 -0
  12. package/dist/content/principles/claude-code-modes.md +47 -0
  13. package/dist/content/principles/clean-code.md +102 -0
  14. package/dist/content/principles/context-budget.md +81 -0
  15. package/dist/content/principles/cqrs.md +70 -0
  16. package/dist/content/principles/domain-modeling.md +62 -0
  17. package/dist/content/principles/frontend-organization.md +120 -0
  18. package/dist/content/principles/language-and-types.md +85 -0
  19. package/dist/content/principles/linting-and-tooling.md +122 -0
  20. package/dist/content/principles/personal-use-tradeoffs.md +55 -0
  21. package/dist/content/principles/testing.md +89 -0
  22. package/dist/orchestrator/blocked-by.d.ts +17 -0
  23. package/dist/orchestrator/blocked-by.d.ts.map +1 -0
  24. package/dist/orchestrator/blocked-by.js +48 -0
  25. package/dist/orchestrator/blocked-by.js.map +1 -0
  26. package/dist/orchestrator/ci-gate.d.ts +28 -0
  27. package/dist/orchestrator/ci-gate.d.ts.map +1 -0
  28. package/dist/orchestrator/ci-gate.js +198 -0
  29. package/dist/orchestrator/ci-gate.js.map +1 -0
  30. package/dist/orchestrator/main.d.ts +10 -0
  31. package/dist/orchestrator/main.d.ts.map +1 -0
  32. package/dist/orchestrator/main.js +883 -0
  33. package/dist/orchestrator/main.js.map +1 -0
  34. package/dist/orchestrator/prereqs.d.ts +30 -0
  35. package/dist/orchestrator/prereqs.d.ts.map +1 -0
  36. package/dist/orchestrator/prereqs.js +191 -0
  37. package/dist/orchestrator/prereqs.js.map +1 -0
  38. package/dist/orchestrator/rejection.d.ts +60 -0
  39. package/dist/orchestrator/rejection.d.ts.map +1 -0
  40. package/dist/orchestrator/rejection.js +187 -0
  41. package/dist/orchestrator/rejection.js.map +1 -0
  42. package/dist/orchestrator/reviewer.d.ts +75 -0
  43. package/dist/orchestrator/reviewer.d.ts.map +1 -0
  44. package/dist/orchestrator/reviewer.js +260 -0
  45. package/dist/orchestrator/reviewer.js.map +1 -0
  46. package/dist/orchestrator/ship.d.ts +19 -0
  47. package/dist/orchestrator/ship.d.ts.map +1 -0
  48. package/dist/orchestrator/ship.js +73 -0
  49. package/dist/orchestrator/ship.js.map +1 -0
  50. package/dist/orchestrator/sibling-context.d.ts +16 -0
  51. package/dist/orchestrator/sibling-context.d.ts.map +1 -0
  52. package/dist/orchestrator/sibling-context.js +61 -0
  53. package/dist/orchestrator/sibling-context.js.map +1 -0
  54. package/dist/orchestrator/splits.d.ts +60 -0
  55. package/dist/orchestrator/splits.d.ts.map +1 -0
  56. package/dist/orchestrator/splits.js +149 -0
  57. package/dist/orchestrator/splits.js.map +1 -0
  58. package/dist/orchestrator/status.d.ts +13 -0
  59. package/dist/orchestrator/status.d.ts.map +1 -0
  60. package/dist/orchestrator/status.js +43 -0
  61. package/dist/orchestrator/status.js.map +1 -0
  62. package/dist/orchestrator/summary.d.ts +33 -0
  63. package/dist/orchestrator/summary.d.ts.map +1 -0
  64. package/dist/orchestrator/summary.js +59 -0
  65. package/dist/orchestrator/summary.js.map +1 -0
  66. package/dist/orchestrator/sweep.d.ts +18 -0
  67. package/dist/orchestrator/sweep.d.ts.map +1 -0
  68. package/dist/orchestrator/sweep.js +79 -0
  69. package/dist/orchestrator/sweep.js.map +1 -0
  70. package/dist/orchestrator/teardown.d.ts +12 -0
  71. package/dist/orchestrator/teardown.d.ts.map +1 -0
  72. package/dist/orchestrator/teardown.js +42 -0
  73. package/dist/orchestrator/teardown.js.map +1 -0
  74. package/dist/orchestrator/worktree-cleanup.d.ts +2 -0
  75. package/dist/orchestrator/worktree-cleanup.d.ts.map +1 -0
  76. package/dist/orchestrator/worktree-cleanup.js +39 -0
  77. package/dist/orchestrator/worktree-cleanup.js.map +1 -0
  78. package/dist/prompts/implementer.md.tpl +85 -0
  79. package/dist/prompts/reviewer.md.tpl +118 -0
  80. package/dist/render-prompt.d.ts +22 -0
  81. package/dist/render-prompt.d.ts.map +1 -0
  82. package/dist/render-prompt.js +64 -0
  83. package/dist/render-prompt.js.map +1 -0
  84. package/dist/stage.d.ts +43 -0
  85. package/dist/stage.d.ts.map +1 -0
  86. package/dist/stage.js +105 -0
  87. package/dist/stage.js.map +1 -0
  88. package/docker/Dockerfile +42 -0
  89. package/package.json +48 -0
@@ -0,0 +1,85 @@
1
+ # Working on issue #{{ISSUE_NUMBER}}
2
+
3
+ You are working on GitHub issue **#{{ISSUE_NUMBER}} — {{ISSUE_TITLE}}** in this repository. Your branch is checked out for you; just make your changes and commit them here.
4
+
5
+ {{SIBLING_CONTEXT}}
6
+
7
+ ## Principles you must follow
8
+
9
+ Before starting work, read [.sandcastle-drain/staged/principles/README.md](.sandcastle-drain/staged/principles/README.md) and the principle files relevant to the change. Two are mandatory in autonomous Sandcastle-drain runs regardless of topic:
10
+
11
+ - [.sandcastle-drain/staged/principles/claude-code-modes.md](.sandcastle-drain/staged/principles/claude-code-modes.md) — universal rules + the autonomous-only deltas (token budget, summarize-don't-paste, no-push, no clarification questions, etc.)
12
+ - [.sandcastle-drain/staged/principles/context-budget.md](.sandcastle-drain/staged/principles/context-budget.md) — 100k target / 150k ceiling, summarize-don't-paste detail
13
+
14
+ If your work touches a layer or topic the issue doesn't make obvious, also read the relevant principle file (e.g. domain code → [.sandcastle-drain/staged/principles/domain-modeling.md](.sandcastle-drain/staged/principles/domain-modeling.md), tests → [.sandcastle-drain/staged/principles/testing.md](.sandcastle-drain/staged/principles/testing.md)).
15
+
16
+ If the issue asks for something the principles forbid (e.g. pushing the branch, opening a PR), do whatever code work is _not_ forbidden, then emit `<promise>COMPLETE</promise>` with a paragraph explaining what was completed and what needs to be split out for the runtime / human.
17
+
18
+ If the issue is genuinely too big for one run, see the **Splitting a too-big issue** section below — you can hand the wrapper a list of follow-ups to file rather than stopping with a paragraph.
19
+
20
+ ## The issue
21
+
22
+ The full body and every comment, including any reviewer feedback from a prior attempt:
23
+
24
+ !`gh issue view {{ISSUE_NUMBER}} --json title,body,labels,comments`
25
+
26
+ ## How to decide what to do
27
+
28
+ Read the issue carefully and decide what kind of work it requires:
29
+
30
+ - A code change with behavior the user can observe → use the `tdd` skill (red → green → refactor; commit after each green).
31
+ - An open-ended bug or performance regression → use the `diagnose` skill.
32
+ - Documentation, configuration, or trivial fixes (one-line, type-only, formatting) → just make the change and commit. Don't invent tests for work that doesn't have testable behavior.
33
+ - Anything ambiguous: emit `<promise>COMPLETE</promise>` with a brief explanation of what you'd want clarified, and stop.
34
+
35
+ If the issue is genuinely too big for a single run, commit what does fit and write the rest into `.sandcastle-drain/splits.json` — see **Splitting a too-big issue** below. Don't half-solve it.
36
+
37
+ ## Commit messages
38
+
39
+ Use a Conventional Commits prefix that fits the work — `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` — and put `Closes #{{ISSUE_NUMBER}}` in the message body so the merge auto-closes the issue:
40
+
41
+ ```
42
+ <prefix>: <short description>
43
+
44
+ Closes #{{ISSUE_NUMBER}}
45
+ ```
46
+
47
+ If you make multiple commits, only the last one needs the `Closes #...` line. Don't always use `feat:` — pick the prefix that matches the actual change.
48
+
49
+ ## Splitting a too-big issue
50
+
51
+ The 150k context ceiling is real. If you can see — early or mid-run — that the issue's acceptance criteria don't fit, the right move is **not** to silently land less than the issue asks for, and **not** to bail with a paragraph the human has to translate into issues by hand. Instead:
52
+
53
+ 1. Commit whatever scope **does** fit. Foundation-style splits (a port + schemas before the adapter, an Application use case before its CLI wiring) are normal — they ship clean, reviewable code that future splits build on.
54
+ 2. Before emitting `<promise>COMPLETE</promise>`, write `.sandcastle-drain/splits.json` at the worktree root. The wrapper reads this file and files each entry as a new `sandcastle` + `priority` GitHub issue, comments on the original linking the follow-ups, and labels the original `oversized`. The next drain picks the follow-ups up automatically.
55
+
56
+ Shape — a JSON array, 1 to 10 entries, each with `title` and `body`:
57
+
58
+ ```json
59
+ [
60
+ {
61
+ "title": "PRD-5 slice 3A: Researcher SDK adapter + Anthropic record/replay fixtures",
62
+ "body": "## Parent\n\nSplit out of #N — the foundation landed in commit ...\n\n## What to build\n\n...\n\n## Acceptance criteria\n\n- [ ] ...\n\n## Blocked by\n\n- #N foundation must merge first."
63
+ }
64
+ ]
65
+ ```
66
+
67
+ Rules:
68
+
69
+ - The `body` is plain markdown — write it the way you'd write any issue body. Lead with `## Parent` (pointing at this issue plus its parent PRD if any), then `## What to build`, then a checkboxed `## Acceptance criteria`, then `## Blocked by` if there's a real dependency. The next implementer's only context is what you write here.
70
+ - Don't repeat the entire parent issue's body. Carry forward the acceptance criteria that map to this split, and reference the parent for full context.
71
+ - If splits depend on each other, list each predecessor in the `## Blocked by` section by the title of the prior split (the wrapper files them in array order, so a later split can reference an earlier one). Title-based references are fine — the human reading the audit trail will see them anyway.
72
+ - 10 entries max. If you genuinely need more, file fewer, larger ones — the queue will let each next drain split further.
73
+ - The file must contain a top-level JSON array. No `{ "splits": [...] }` wrapper.
74
+
75
+ The classic example: commit `c337feb` on `agent/issue-49` landed the Researcher port + system prompt + Zod schemas and named splits A / B / C in its commit message. That's exactly the kind of split this protocol exists for — but the wrapper at that point didn't act on commit-message hints, so the splits had to be filed by hand. Don't write your splits in a commit message expecting the wrapper to parse them. Write them in `.sandcastle-drain/splits.json`.
76
+
77
+ After writing the file, emit `<promise>COMPLETE</promise>` as usual.
78
+
79
+ ## Do not push or open PRs
80
+
81
+ Do not run `git push`, `gh pr create`, or any command that publishes work outside this worktree. Your branch stays local — the human will review and push it after the run. (`gh issue comment` is fine if you genuinely need to ask something on the issue.)
82
+
83
+ ## When you are done
84
+
85
+ Emit `<promise>COMPLETE</promise>` once, on its own line, after your final commit. If you are bailing out without committing, emit it after a one-paragraph explanation of what is blocking you.
@@ -0,0 +1,118 @@
1
+ ---
2
+ name: reviewer
3
+ model: claude-opus-4-7
4
+ tools: [Read, Grep, Glob, Bash]
5
+ description: Advisory reviewer sub-agent. Reads the implementer's diff for issue #{{ISSUE_NUMBER}}, applies the project's principle / ADR / glossary rubric, and posts a structured JSON verdict. Read-only — does not modify the worktree, does not commit, does not push.
6
+ ---
7
+
8
+ # Reviewer for issue #{{ISSUE_NUMBER}}
9
+
10
+ You are an **advisory reviewer**. The implementer agent just made commits on `{{BRANCH}}`. Your job is to read the diff against `main`, apply the rubric below, and emit a structured JSON verdict that the wrapper will post as a comment on the issue.
11
+
12
+ You are **read-only**:
13
+
14
+ - Do not modify files. Do not stage or commit. Do not run `git push`, `gh pr create`, or any command that publishes work.
15
+ - Allowed tools: `Read`, `Grep`, `Glob`, `Bash` (read-only commands only — `git diff`, `git log`, `ls`, etc.). Do not use the Edit or Write tools.
16
+
17
+ You are **advisory, not gating**. A `FAIL` verdict produces a comment for the human reviewer to weigh; it does not block the merge. Be useful, not pedantic.
18
+
19
+ ## Step 1 — Eager-load the rubric
20
+
21
+ Before reading the diff, load the principles, glossary, and ADR index into context. These are the documents the implementer was bound by, and they define what you check against. Read them in this order:
22
+
23
+ 1. **Principles** — every file in `.sandcastle-drain/staged/principles/`:
24
+ - `.sandcastle-drain/staged/principles/README.md`
25
+ - `.sandcastle-drain/staged/principles/architecture.md`
26
+ - `.sandcastle-drain/staged/principles/language-and-types.md`
27
+ - `.sandcastle-drain/staged/principles/cqrs.md`
28
+ - `.sandcastle-drain/staged/principles/domain-modeling.md`
29
+ - `.sandcastle-drain/staged/principles/testing.md`
30
+ - `.sandcastle-drain/staged/principles/linting-and-tooling.md`
31
+ - `.sandcastle-drain/staged/principles/clean-code.md`
32
+ - `.sandcastle-drain/staged/principles/personal-use-tradeoffs.md`
33
+ - `.sandcastle-drain/staged/principles/context-budget.md`
34
+ - `.sandcastle-drain/staged/principles/claude-code-modes.md`
35
+ - `.sandcastle-drain/staged/principles/frontend-organization.md`
36
+ {{#if HAS_CONTEXT_MD}}2. **Glossary** — `CONTEXT.md` (canonical domain vocabulary; names in code must match)
37
+ {{/if}}{{#if HAS_ADRS}}3. **ADR index** — list `docs/adr/` and skim the filenames. Read the body of any ADR you need to cite in a finding.
38
+ {{/if}}
39
+ If `Glob` returns a principle file not listed above, read it too — the README is the source of truth.
40
+
41
+ ## Step 2 — Read the diff
42
+
43
+ Use `Bash` to read the implementer's commits:
44
+
45
+ - `git diff main..HEAD` — the unified diff. This is what you review.
46
+ - `git log --format='%h %s' main..HEAD` — the commit titles, for context.
47
+
48
+ These are the implementer's commits. They are what you review.
49
+
50
+ If the diff is empty, emit `verdict: "PASS"` with an empty `findings` array and a one-line summary saying so.
51
+
52
+ ## Step 3 — Apply the rubric
53
+
54
+ For each category below, the implementer is bound to the listed rules. Look for **violations** in the diff, not in unchanged code.
55
+
56
+ The first three categories are project-agnostic principle checks.{{#if HAS_PROJECT_RULES}} The fourth (Glossary & ADR alignment) is where project-specific rules live — consult the sources listed under it to discover what they are.{{/if}}
57
+
58
+ ### Domain integrity
59
+
60
+ - **Anemic-model ban** — domain entities own their state transitions; getters/setters with logic in services are violations. See `.sandcastle-drain/staged/principles/domain-modeling.md`.
61
+ {{#if HAS_PROJECT_RULES}}- **Project-specific aggregate rules** — the project's documentation may define invariants for specific aggregates (e.g. "association X is reified as its own aggregate," "state Y cannot be reached without a fresh Z record," "value W is derived not stored"). Read what Step 1 told you to load, and flag any diff that violates a written rule. Cite the source in the `principle` field.
62
+ {{/if}}
63
+ ### Test discipline
64
+
65
+ - **Behavior-required rule** — every commit that introduces testable behavior ships with tests. Type-only, formatting-only, and docs-only changes are exempt. See `.sandcastle-drain/staged/principles/testing.md`.
66
+ - **Property-based on state machines** — state-transition logic uses `fast-check` properties, not just example tests. See `.sandcastle-drain/staged/principles/testing.md`.
67
+ - **Integration tests hit real infrastructure** — no in-memory mocks for databases or external services. Use `testcontainers` (or equivalent). See `.sandcastle-drain/staged/principles/testing.md`.
68
+
69
+ ### Architecture intent
70
+
71
+ - **Composition over inheritance** — no `extends` of domain classes; behavior composed via functions / strategies. See `.sandcastle-drain/staged/principles/clean-code.md`.
72
+ - **Pure domain** — the domain layer has no I/O, no `Date.now()`, no `Math.random()` outside parameterized factories. See `.sandcastle-drain/staged/principles/architecture.md`.
73
+ - **Layer-inward dependencies** — `domain` → nothing; `application` → `domain`; `external` → `application`/`domain`; `apps` → all. Lint-enforced via `eslint-plugin-boundaries`; check the diff doesn't add cross-layer imports the lint rules will flag.
74
+
75
+ {{#if HAS_PROJECT_RULES}}### Glossary & ADR alignment
76
+
77
+ {{/if}}{{#if HAS_CONTEXT_MD}}- **CONTEXT.md verbatim names** — every new type / table / file-path / UI-label uses the exact names defined in `CONTEXT.md`. Synonyms are violations. See `.sandcastle-drain/staged/principles/domain-modeling.md` (nomenclature binding).
78
+ {{/if}}{{#if HAS_ADRS}}- **ADR mapping** — if the change touches a topic covered by an ADR in `docs/adr/`, the change must align with it. If it contradicts an ADR, cite the ADR number in the `principle` field.
79
+ {{/if}}
80
+ ## Step 4 — Emit the verdict
81
+
82
+ Your **final message** must contain exactly one fenced JSON block with this shape, and nothing after it:
83
+
84
+ ````
85
+ ```json
86
+ {
87
+ "verdict": "PASS",
88
+ "findings": [
89
+ {
90
+ "severity": "high",
91
+ "principle": "domain-modeling.md / anemic-model ban",
92
+ "file": "packages/domain/src/order.ts",
93
+ "line": 42,
94
+ "message": "Order exposes a public `setStatus` mutator; state transitions belong on the aggregate as named methods that enforce the entity's invariants.",
95
+ "suggestedFix": "Replace `setStatus` with intention-revealing methods (`cancel()`, `ship()`, etc.) that validate the transition and produce the new state."
96
+ }
97
+ ],
98
+ "summary": "One domain-integrity issue and one missing property-based test. Recommend addressing before merge."
99
+ }
100
+ ```
101
+ ````
102
+
103
+ Field rules:
104
+
105
+ - `verdict` — `"PASS"` if there are no `severity: "high"` findings; `"FAIL"` otherwise. `medium` / `low` findings do not flip the verdict alone.
106
+ - `findings` — array, possibly empty. Each finding has all six fields. Use absolute repo-relative paths (`packages/...`, `apps/...`, `docs/...`).
107
+ - `severity` — `"high"` for principle violations or ADR contradictions; `"medium"` for nomenclature drift or missing tests on testable behavior; `"low"` for stylistic / clean-code nits.
108
+ - `principle` — short reference to the rule the finding is grounded in (file path + concept). Required — a finding without a principle reference is a code-review opinion, not a rubric check.
109
+ - `line` — best-effort line number from the diff. Use `0` if you can't pin it to a line.
110
+ - `message` — one or two sentences. State the violation, not how to spot it.
111
+ - `suggestedFix` — one sentence describing the change. Keep it concrete.
112
+ - `summary` — three sentences max. The human reads this on the issue. Lead with the verdict, then the most important finding, then a sentence on overall shape.
113
+
114
+ Do not include prose before or after the JSON block in your final message. Earlier messages (Read tool calls, thinking) are free-form.
115
+
116
+ ## When you are done
117
+
118
+ Emit `<promise>COMPLETE</promise>` on its own line after the JSON block.
@@ -0,0 +1,22 @@
1
+ export type PromptName = 'implementer' | 'reviewer';
2
+ /**
3
+ * Resolves `{{#if FLAG}}…{{/if}}` blocks, then replaces every `{{KEY}}` with
4
+ * `vars[KEY]`. Throws if a placeholder or conditional names a key/flag not
5
+ * supplied — silently leaving `{{ISSUE_NUMBER}}` or `{{#if X}}` in a rendered
6
+ * prompt would surface as a confusing agent failure rather than a clear setup
7
+ * error.
8
+ *
9
+ * Conditional blocks are stripped (when the flag is false) before the
10
+ * placeholder pass runs, so placeholders inside a stripped block don't need
11
+ * to appear in `vars`.
12
+ */
13
+ export declare function substitute(template: string, vars: Record<string, string>, flags?: Record<string, boolean>): string;
14
+ /**
15
+ * Reads the named prompt template from the library's bundled `prompts/`
16
+ * directory and returns it with `vars` substituted and `flags` resolved. The
17
+ * orchestrator owns the variable + flag contract per template (implementer:
18
+ * ISSUE_NUMBER + ISSUE_TITLE + SIBLING_CONTEXT, no flags; reviewer:
19
+ * ISSUE_NUMBER + BRANCH, plus HAS_CONTEXT_MD + HAS_ADRS).
20
+ */
21
+ export declare function renderPrompt(name: PromptName, vars: Record<string, string>, flags?: Record<string, boolean>): Promise<string>;
22
+ //# sourceMappingURL=render-prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-prompt.d.ts","sourceRoot":"","sources":["../src/render-prompt.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,UAAU,GAAG,aAAa,GAAG,UAAU,CAAC;AAUpD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CACxB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,MAAM,CAaR;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,OAAO,CAAC,MAAM,CAAC,CAKjB"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Renders the bundled implementer / reviewer prompt templates with
3
+ * caller-supplied `{{KEY}}` substitutions. The orchestrator passes the result
4
+ * as `prompt: <string>` to `sandcastle.run()` so the host's `.sandcastle-drain/`
5
+ * directory never needs to materialize a `prompt.md` / `reviewer.md` file.
6
+ *
7
+ * Templates live next to the compiled output as `dist/prompts/*.md.tpl` (the
8
+ * `.tpl` suffix is documentary — these are source templates, not finished
9
+ * prompts). The build step (`scripts/copy-library-assets.mjs`) copies them
10
+ * from `src/prompts/` so `import.meta.dirname` resolves identically under tsx
11
+ * (dev) and node (`dist/`).
12
+ *
13
+ * The renderer also handles `{{#if FLAG}}…{{/if}}` blocks so the reviewer
14
+ * rubric can degrade gracefully when host content (CONTEXT.md, docs/adr/) is
15
+ * absent. Conditionals are stripped or retained in a single regex pass before
16
+ * `{{KEY}}` substitution runs; no nesting, no `{{else}}`.
17
+ */
18
+ import { readFile } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ const PROMPT_FILES = {
21
+ implementer: 'implementer.md.tpl',
22
+ reviewer: 'reviewer.md.tpl',
23
+ };
24
+ const PLACEHOLDER_REGEX = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
25
+ const CONDITIONAL_REGEX = /\{\{#if ([A-Z_][A-Z0-9_]*)\}\}([\s\S]*?)\{\{\/if\}\}/g;
26
+ /**
27
+ * Resolves `{{#if FLAG}}…{{/if}}` blocks, then replaces every `{{KEY}}` with
28
+ * `vars[KEY]`. Throws if a placeholder or conditional names a key/flag not
29
+ * supplied — silently leaving `{{ISSUE_NUMBER}}` or `{{#if X}}` in a rendered
30
+ * prompt would surface as a confusing agent failure rather than a clear setup
31
+ * error.
32
+ *
33
+ * Conditional blocks are stripped (when the flag is false) before the
34
+ * placeholder pass runs, so placeholders inside a stripped block don't need
35
+ * to appear in `vars`.
36
+ */
37
+ export function substitute(template, vars, flags = {}) {
38
+ const resolved = template.replace(CONDITIONAL_REGEX, (_match, flag, body) => {
39
+ if (!Object.prototype.hasOwnProperty.call(flags, flag)) {
40
+ throw new Error(`render-prompt: missing template flag {{#if ${flag}}}`);
41
+ }
42
+ return flags[flag] ? body : '';
43
+ });
44
+ return resolved.replace(PLACEHOLDER_REGEX, (_match, key) => {
45
+ if (!Object.prototype.hasOwnProperty.call(vars, key)) {
46
+ throw new Error(`render-prompt: missing template variable {{${key}}}`);
47
+ }
48
+ return vars[key];
49
+ });
50
+ }
51
+ /**
52
+ * Reads the named prompt template from the library's bundled `prompts/`
53
+ * directory and returns it with `vars` substituted and `flags` resolved. The
54
+ * orchestrator owns the variable + flag contract per template (implementer:
55
+ * ISSUE_NUMBER + ISSUE_TITLE + SIBLING_CONTEXT, no flags; reviewer:
56
+ * ISSUE_NUMBER + BRANCH, plus HAS_CONTEXT_MD + HAS_ADRS).
57
+ */
58
+ export async function renderPrompt(name, vars, flags = {}) {
59
+ const libraryRoot = import.meta.dirname;
60
+ const path = join(libraryRoot, 'prompts', PROMPT_FILES[name]);
61
+ const template = await readFile(path, 'utf8');
62
+ return substitute(template, vars, flags);
63
+ }
64
+ //# sourceMappingURL=render-prompt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-prompt.js","sourceRoot":"","sources":["../src/render-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAIjC,MAAM,YAAY,GAA+B;IAC/C,WAAW,EAAE,oBAAoB;IACjC,QAAQ,EAAE,iBAAiB;CAC5B,CAAC;AAEF,MAAM,iBAAiB,GAAG,6BAA6B,CAAC;AACxD,MAAM,iBAAiB,GAAG,uDAAuD,CAAC;AAElF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,UAAU,CACxB,QAAgB,EAChB,IAA4B,EAC5B,QAAiC,EAAE;IAEnC,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,iBAAiB,EAAE,CAAC,MAAM,EAAE,IAAY,EAAE,IAAY,EAAE,EAAE;QAC1F,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC;YACvD,MAAM,IAAI,KAAK,CAAC,8CAA8C,IAAI,IAAI,CAAC,CAAC;QAC1E,CAAC;QACD,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;IACjC,CAAC,CAAC,CAAC;IACH,OAAO,QAAQ,CAAC,OAAO,CAAC,iBAAiB,EAAE,CAAC,MAAM,EAAE,GAAW,EAAE,EAAE;QACjE,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC;YACrD,MAAM,IAAI,KAAK,CAAC,8CAA8C,GAAG,IAAI,CAAC,CAAC;QACzE,CAAC;QACD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;IACnB,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,IAAgB,EAChB,IAA4B,EAC5B,QAAiC,EAAE;IAEnC,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC;IACxC,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;IAC9D,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC9C,OAAO,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;AAC3C,CAAC"}
@@ -0,0 +1,43 @@
1
+ export declare const STAGED_DIR_RELATIVE = ".sandcastle-drain/staged";
2
+ /**
3
+ * Absolute POSIX path inside the sandbox where the staged content is mounted.
4
+ * Sandcastle mounts each worktree at /home/agent/workspace (documented in
5
+ * @ai-hero/sandcastle's MountConfig). Passing an absolute POSIX sandboxPath
6
+ * sidesteps sandcastle's platform-native path.resolve, which on Windows
7
+ * mangles relative sandboxPath values into `C:\home\agent\workspace\...`
8
+ * and triggers Docker's "too many colons" mount parser.
9
+ */
10
+ export declare const STAGED_SANDBOX_PATH = "/home/agent/workspace/.sandcastle-drain/staged";
11
+ /**
12
+ * Idempotently writes the library's content into `<cwd>/.sandcastle-drain/staged/`.
13
+ * Removes any prior staged tree first so a library upgrade is reflected
14
+ * immediately rather than merged into stale files.
15
+ *
16
+ * Safe to call once per CLI invocation, before the drain loop begins.
17
+ */
18
+ export declare function stage(cwd: string): Promise<void>;
19
+ /**
20
+ * Rubric-availability flags the reviewer template branches on. `hasContextMd`
21
+ * is true when `CONTEXT.md` exists at the host root and is non-empty (an empty
22
+ * stub shouldn't enable the glossary rubric category). `hasAdrs` is true when
23
+ * `docs/adr/` exists at the host root and contains at least one `.md` file
24
+ * other than `README.md`.
25
+ */
26
+ export interface HostRubricFlags {
27
+ readonly hasContextMd: boolean;
28
+ readonly hasAdrs: boolean;
29
+ }
30
+ /**
31
+ * Detects whether the host project has a populated `CONTEXT.md` and/or
32
+ * non-empty `docs/adr/` directory. Memoized per `cwd` so the reviewer can call
33
+ * this once per issue and only the first call hits disk.
34
+ *
35
+ * Exposed for the orchestrator to pass into the reviewer's prompt rendering.
36
+ * The wrapper does not call this for the implementer prompt — the implementer
37
+ * is supposed to populate CONTEXT.md / ADRs as part of its work and shouldn't
38
+ * be told they're absent.
39
+ */
40
+ export declare function detectRubricFlags(cwd: string): HostRubricFlags;
41
+ /** Test-only escape hatch: clears the memoization table. */
42
+ export declare function resetRubricFlagsCache(): void;
43
+ //# sourceMappingURL=stage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stage.d.ts","sourceRoot":"","sources":["../src/stage.ts"],"names":[],"mappings":"AA0BA,eAAO,MAAM,mBAAmB,6BAA6B,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,mDAAmD,CAAC;AAEpF;;;;;;GAMG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CActD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAID;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAS9D;AAED,4DAA4D;AAC5D,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C"}
package/dist/stage.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Stages library-bundled markdown into the host project's `.sandcastle-drain/staged/`
3
+ * so the implementer and reviewer agents — running inside per-issue worktrees —
4
+ * can read the canonical principles and agent-docs from inside the sandbox.
5
+ *
6
+ * The orchestrator bind-mounts `<host-cwd>/.sandcastle-drain/staged/` into each
7
+ * per-issue sandbox at the same relative path (read-only), so the agent can
8
+ * `Read .sandcastle-drain/staged/principles/testing.md` from inside the worktree.
9
+ *
10
+ * Prompt templates are NOT staged here — they're rendered in memory by
11
+ * `src/render-prompt.ts` and passed to `sandcastle.run()` as `prompt: <string>`.
12
+ *
13
+ * Library content is resolved relative to `import.meta.dirname`. The build
14
+ * script (`scripts/copy-library-assets.mjs`) copies `src/content/` next to the
15
+ * compiled `dist/stage.js` so the same resolver works under tsx (dev) and node
16
+ * (`dist/`).
17
+ *
18
+ * This module also owns host-rubric-flag detection (`detectRubricFlags`) so the
19
+ * reviewer prompt can degrade gracefully when `CONTEXT.md` or `docs/adr/` are
20
+ * absent or empty. The result is memoized per cwd so a 200-issue drain pays
21
+ * for the disk probes once.
22
+ */
23
+ import { cp, mkdir, rm } from 'node:fs/promises';
24
+ import { existsSync, readdirSync, statSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ export const STAGED_DIR_RELATIVE = '.sandcastle-drain/staged';
27
+ /**
28
+ * Absolute POSIX path inside the sandbox where the staged content is mounted.
29
+ * Sandcastle mounts each worktree at /home/agent/workspace (documented in
30
+ * @ai-hero/sandcastle's MountConfig). Passing an absolute POSIX sandboxPath
31
+ * sidesteps sandcastle's platform-native path.resolve, which on Windows
32
+ * mangles relative sandboxPath values into `C:\home\agent\workspace\...`
33
+ * and triggers Docker's "too many colons" mount parser.
34
+ */
35
+ export const STAGED_SANDBOX_PATH = '/home/agent/workspace/.sandcastle-drain/staged';
36
+ /**
37
+ * Idempotently writes the library's content into `<cwd>/.sandcastle-drain/staged/`.
38
+ * Removes any prior staged tree first so a library upgrade is reflected
39
+ * immediately rather than merged into stale files.
40
+ *
41
+ * Safe to call once per CLI invocation, before the drain loop begins.
42
+ */
43
+ export async function stage(cwd) {
44
+ const libraryRoot = import.meta.dirname;
45
+ const libraryContent = join(libraryRoot, 'content');
46
+ const stagedDir = join(cwd, STAGED_DIR_RELATIVE);
47
+ const stagedPrinciples = join(stagedDir, 'principles');
48
+ const stagedAgentDocs = join(stagedDir, 'agent-docs');
49
+ await rm(stagedDir, { recursive: true, force: true });
50
+ await mkdir(stagedPrinciples, { recursive: true });
51
+ await mkdir(stagedAgentDocs, { recursive: true });
52
+ await cp(join(libraryContent, 'principles'), stagedPrinciples, { recursive: true });
53
+ await cp(join(libraryContent, 'agent-docs'), stagedAgentDocs, { recursive: true });
54
+ }
55
+ const rubricFlagsCache = new Map();
56
+ /**
57
+ * Detects whether the host project has a populated `CONTEXT.md` and/or
58
+ * non-empty `docs/adr/` directory. Memoized per `cwd` so the reviewer can call
59
+ * this once per issue and only the first call hits disk.
60
+ *
61
+ * Exposed for the orchestrator to pass into the reviewer's prompt rendering.
62
+ * The wrapper does not call this for the implementer prompt — the implementer
63
+ * is supposed to populate CONTEXT.md / ADRs as part of its work and shouldn't
64
+ * be told they're absent.
65
+ */
66
+ export function detectRubricFlags(cwd) {
67
+ const cached = rubricFlagsCache.get(cwd);
68
+ if (cached !== undefined)
69
+ return cached;
70
+ const result = {
71
+ hasContextMd: hasNonEmptyContextMd(cwd),
72
+ hasAdrs: hasAnyAdr(cwd),
73
+ };
74
+ rubricFlagsCache.set(cwd, result);
75
+ return result;
76
+ }
77
+ /** Test-only escape hatch: clears the memoization table. */
78
+ export function resetRubricFlagsCache() {
79
+ rubricFlagsCache.clear();
80
+ }
81
+ function hasNonEmptyContextMd(cwd) {
82
+ const path = join(cwd, 'CONTEXT.md');
83
+ if (!existsSync(path))
84
+ return false;
85
+ try {
86
+ return statSync(path).size > 0;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ function hasAnyAdr(cwd) {
93
+ const dir = join(cwd, 'docs', 'adr');
94
+ if (!existsSync(dir))
95
+ return false;
96
+ let entries;
97
+ try {
98
+ entries = readdirSync(dir);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ return entries.some((name) => name.endsWith('.md') && name !== 'README.md');
104
+ }
105
+ //# sourceMappingURL=stage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stage.js","sourceRoot":"","sources":["../src/stage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ FROM node:22-bookworm
2
+
3
+ # Install system dependencies
4
+ RUN apt-get update && apt-get install -y \
5
+ git \
6
+ curl \
7
+ jq \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Install GitHub CLI
11
+ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
12
+ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
13
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
14
+ | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
15
+ && apt-get update && apt-get install -y gh \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Install Playwright + Chromium system dependencies and the browser itself.
19
+ # Run as root before USER agent so apt-get can install libs. Browsers go to
20
+ # a world-readable shared path so the non-root agent user can launch them.
21
+ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
22
+ RUN npm install -g playwright@1.49.0 \
23
+ && npx --yes playwright@1.49.0 install --with-deps chromium \
24
+ && chmod -R a+rx /ms-playwright
25
+
26
+ # Rename the base image's "node" user (UID 1000) to "agent".
27
+ # This keeps UID 1000 so that --userns=keep-id (Podman) and
28
+ # --user 1000:1000 (Docker) map to the correct home directory owner.
29
+ RUN usermod -d /home/agent -m -l agent node
30
+ USER agent
31
+
32
+ # Install Claude Code CLI
33
+ RUN curl -fsSL https://claude.ai/install.sh | bash
34
+
35
+ # Add Claude to PATH
36
+ ENV PATH="/home/agent/.local/bin:$PATH"
37
+
38
+ WORKDIR /home/agent
39
+
40
+ # In worktree sandbox mode, Sandcastle bind-mounts the git worktree at the
41
+ # project root and overrides the working directory at container start.
42
+ ENTRYPOINT ["sleep", "infinity"]
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "sandcastle-drain",
3
+ "version": "0.1.0",
4
+ "description": "Queue wrapper around @ai-hero/sandcastle: drains GitHub issues labelled `sandcastle` by running an autonomous Claude Code agent inside a Docker sandbox.",
5
+ "license": "MIT",
6
+ "author": "Downeys",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Downeys/sandcastle-drain.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Downeys/sandcastle-drain/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "sandcastle-drain": "dist/cli.js"
17
+ },
18
+ "files": [
19
+ "dist/**/*",
20
+ "docker/Dockerfile",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "postbuild": "node scripts/copy-library-assets.mjs",
30
+ "prepublishOnly": "npm run build",
31
+ "drain": "tsx src/cli.ts drain",
32
+ "ship": "tsx src/cli.ts ship",
33
+ "sweep": "tsx src/cli.ts sweep",
34
+ "typecheck": "tsc --noEmit",
35
+ "lint": "echo \"lint: no linter configured\"",
36
+ "test": "vitest run"
37
+ },
38
+ "dependencies": {
39
+ "@ai-hero/sandcastle": "0.5.7",
40
+ "execa": "^9.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.0.0",
44
+ "tsx": "^4.19.0",
45
+ "typescript": "^5.6.0",
46
+ "vitest": "^2.1.0"
47
+ }
48
+ }