@amenophis1er/foreman 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 (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@amenophis1er/foreman",
3
+ "version": "0.1.0",
4
+ "description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
5
+ "keywords": [
6
+ "claude",
7
+ "agent",
8
+ "agents",
9
+ "orchestrator",
10
+ "autonomous",
11
+ "mission",
12
+ "claude-code",
13
+ "dashboard"
14
+ ],
15
+ "homepage": "https://github.com/amenophis1er/foreman#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/amenophis1er/foreman/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/amenophis1er/foreman.git"
22
+ },
23
+ "license": "ISC",
24
+ "author": "Amen AMOUZOU",
25
+ "type": "module",
26
+ "bin": {
27
+ "foreman": "./bin/foreman.mjs"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "src",
32
+ "scripts/prepare.mjs",
33
+ "ui/dist",
34
+ "skills",
35
+ "README.md",
36
+ "DESIGN.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "scripts": {
43
+ "test": "node --import tsx --test 'src/**/*.test.ts'",
44
+ "start": "tsx src/server.ts",
45
+ "dev": "node scripts/dev.mjs",
46
+ "ui:dev": "npm --prefix ui run dev",
47
+ "ui:build": "npm --prefix ui run build",
48
+ "prepack": "node scripts/prepare.mjs --force",
49
+ "typecheck": "tsc --noEmit -p . && npm --prefix ui run typecheck",
50
+ "doctor": "node bin/foreman.mjs doctor",
51
+ "setup": "node scripts/prepare.mjs --force"
52
+ },
53
+ "dependencies": {
54
+ "@anthropic-ai/claude-agent-sdk": "^0.3.259",
55
+ "@playwright/mcp": "^0.0.80",
56
+ "qrcode": "^1.5.4",
57
+ "tsx": "^4.23.13",
58
+ "zod": "^4.5.4"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^26.4.1",
62
+ "@types/qrcode": "^1.5.6",
63
+ "typescript": "^7.0.2"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // Builds the dashboard so the package ships with one:
3
+ // npm publish runs it via prepack (--force) — every tarball has ui/dist
4
+ // npm run setup in a checkout, after npm ci
5
+ // Not a `prepare`/`postinstall` hook on purpose: npm 11 warns about (and
6
+ // blocks) lifecycle scripts on global installs, and the tarball already has
7
+ // the build. Skips when nothing changed; `--force` always builds.
8
+ // Never fails an install that cannot build: the API still works without the
9
+ // dashboard, and the preflight says so on start.
10
+ import { execSync } from 'node:child_process';
11
+ import { existsSync, readdirSync, statSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const ui = path.join(root, 'ui');
17
+ const dist = path.join(ui, 'dist', 'index.html');
18
+ const force = process.argv.includes('--force');
19
+ const quiet = process.env.FOREMAN_SKIP_UI_BUILD === '1';
20
+
21
+ if (quiet) process.exit(0);
22
+ if (!existsSync(path.join(ui, 'package.json'))) process.exit(0); // not shipped with sources — nothing to build
23
+
24
+ function newest(dir) {
25
+ let t = 0;
26
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
27
+ if (e.name === 'node_modules' || e.name === 'dist') continue;
28
+ const p = path.join(dir, e.name);
29
+ t = Math.max(t, e.isDirectory() ? newest(p) : statSync(p).mtimeMs);
30
+ }
31
+ return t;
32
+ }
33
+
34
+ const stale = force || !existsSync(dist) || newest(path.join(ui, 'src')) > statSync(dist).mtimeMs
35
+ || statSync(path.join(ui, 'package.json')).mtimeMs > statSync(dist).mtimeMs;
36
+ if (!stale) process.exit(0);
37
+
38
+ try {
39
+ if (!existsSync(path.join(ui, 'node_modules'))) {
40
+ console.log('foreman: installing dashboard dependencies…');
41
+ execSync('npm ci --no-audit --no-fund', { cwd: ui, stdio: 'inherit' });
42
+ }
43
+ console.log('foreman: building the dashboard…');
44
+ execSync('npm run build', { cwd: ui, stdio: 'inherit' });
45
+ } catch (err) {
46
+ if (force) { console.error('foreman: dashboard build failed — refusing to pack without it'); process.exit(1); }
47
+ console.warn(`foreman: dashboard build skipped (${err.message.split('\n')[0]}). The API works; the dashboard will not until \`npm run ui:build\`.`);
48
+ }
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: director
3
+ description: Kick off a Foreman mission for the current project — ensures the Foreman server is running, links the session's cwd as a project, starts a director-led mission with the given brief, and opens the dashboard. Usage - /director <mission brief> [--budget N] [--worker sonnet|haiku|opus] [--director opus|sonnet|haiku]
4
+ ---
5
+
6
+ # /director — launch a Foreman mission from this session
7
+
8
+ You are the launcher only. The mission itself runs in Foreman's own director
9
+ and worker sessions with budgets, approval cards, and MISSION.md governance;
10
+ the human supervises from the dashboard, not from this session. Do NOT do the
11
+ mission's work yourself here.
12
+
13
+ Foreman serves `http://localhost:4177` and is started with the `foreman`
14
+ command. Set `FOREMAN_URL` to point at a different host or port.
15
+
16
+ ## Steps
17
+
18
+ 1. **Safety guard.** If the current working directory is inside a Foreman
19
+ checkout, STOP and tell the user: Foreman must never run missions on itself
20
+ (oversight-infrastructure rule). A directory whose `package.json` has a
21
+ `foreman` bin, or which contains `src/orchestrator.ts` alongside
22
+ `src/policy.ts`, is a Foreman checkout.
23
+
24
+ 2. **Parse the arguments.** Everything except the flags is the mission brief.
25
+ Flags: `--budget N` (default 5), `--worker <model>`, `--director <model>`
26
+ (models: opus | sonnet | haiku; omit for default). If there is no brief at
27
+ all, skip steps 4–5: just ensure the server (step 3), link the project
28
+ (step 4's curl), and open the dashboard.
29
+
30
+ 3. **Ensure the server is up** (start detached if not):
31
+ ```bash
32
+ curl -sf -m 2 "${FOREMAN_URL:-http://localhost:4177}/projects" >/dev/null || \
33
+ (nohup foreman start > /tmp/foreman-server.log 2>&1 & disown; sleep 3)
34
+ ```
35
+
36
+ 4. **Link the cwd as a project** (idempotent — relinking returns the existing
37
+ project). Capture the project id from the JSON response:
38
+ ```bash
39
+ curl -s -X POST "${FOREMAN_URL:-http://localhost:4177}/projects" \
40
+ -H 'content-type: application/json' \
41
+ -d "{\"folder\": \"$PWD\"}"
42
+ ```
43
+
44
+ 5. **Start the mission** (include directorModel/workerModel keys only when
45
+ the flags were given):
46
+ ```bash
47
+ curl -s -X POST "${FOREMAN_URL:-http://localhost:4177}/run" \
48
+ -H 'content-type: application/json' \
49
+ -d '{"projectId": "<id>", "mission": "<brief>", "budgetUsd": <budget>}'
50
+ ```
51
+ A 409 means this project already has an active mission — report that and
52
+ still open the dashboard.
53
+
54
+ 6. **Open the dashboard and hand off.** Auto-open the project view
55
+ (ignore failure — e.g. over SSH):
56
+ ```bash
57
+ open "${FOREMAN_URL:-http://localhost:4177}/#/p/<projectId>" 2>/dev/null || true
58
+ ```
59
+ Then ALWAYS print the control URL verbatim in your final message — it is
60
+ the single place to approve tools and answer the director's questions:
61
+
62
+ > Mission running under a $N budget.
63
+ > **Control it here: http://localhost:4177/#/p/<projectId>**
64
+ > Approvals and questions wait there indefinitely (nothing expires);
65
+ > this terminal session is free for other work.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The Anthropic model list — the one catalogue Foreman cannot ask for.
3
+ *
4
+ * **Edit this file when Anthropic ships or retires a model.** It is the only
5
+ * hardcoded model list in the product, and it is deliberately in a file of its
6
+ * own so it is obvious where to look.
7
+ *
8
+ * Every other provider is queried live: Ollama answers `/api/tags`, an
9
+ * OpenAI-compatible endpoint answers `/v1/models`, and Codex keeps its own
10
+ * `models_cache.json` that the CLI maintains. Anthropic has an API for this
11
+ * too — `GET /v1/models` — but it needs an API key, and Foreman's common case
12
+ * is a Claude Code *subscription*, which has no key to send. So this list is
13
+ * the fallback for the case nothing can be queried.
14
+ *
15
+ * What actually reaches the SDK is the `id` — the alias. Aliases keep
16
+ * resolving as Anthropic updates the models behind them, so a stale `model`
17
+ * string here misleads the eye without breaking a run. That is the reason this
18
+ * list drifting is survivable, not a reason to leave it wrong.
19
+ *
20
+ * `cost` is a 1–4 relative rank for the picker's bars, not a price.
21
+ */
22
+ export interface CatalogModel {
23
+ /** Sent to the SDK as `options.model`. An alias, so it survives model updates. */
24
+ id: string;
25
+ label: string;
26
+ /** The concrete model behind the alias, shown in mono. Display only. */
27
+ model: string;
28
+ cost: 1 | 2 | 3 | 4;
29
+ note: string;
30
+ }
31
+
32
+ export const ANTHROPIC_MODELS: CatalogModel[] = [
33
+ {
34
+ id: 'fable', label: 'Fable', model: 'claude-fable-5-1', cost: 4,
35
+ note: 'Frontier. Long-horizon planning and verification.',
36
+ },
37
+ {
38
+ id: 'opus', label: 'Opus', model: 'claude-opus-5', cost: 3,
39
+ note: 'Deep reasoning for hard refactors.',
40
+ },
41
+ {
42
+ id: 'sonnet', label: 'Sonnet', model: 'claude-sonnet-5', cost: 2,
43
+ note: 'Balanced. The usual worker.',
44
+ },
45
+ {
46
+ id: 'haiku', label: 'Haiku', model: 'claude-haiku-4-5', cost: 1,
47
+ note: 'Fast and cheap for reads and mechanical edits.',
48
+ },
49
+ ];
50
+
51
+ /** Aliases, for the places that need to know a choice is an Anthropic one. */
52
+ export const ANTHROPIC_ALIASES: ReadonlySet<string> = new Set(
53
+ ANTHROPIC_MODELS.map((m) => m.id),
54
+ );
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Asking a human — the mechanics every ask surface shares.
3
+ *
4
+ * What is pinned here is not the picker; it is the contract between a model
5
+ * that asked in structured form and the prose it gets back, plus the one rule
6
+ * with no exceptions: every ask carries an unattended default, and `0` means
7
+ * "never", not "immediately".
8
+ */
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { armAskTimeout, formatAnswers, normaliseQuestions } from './ask.js';
12
+
13
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
14
+
15
+ test('answers are echoed question by question, in the order asked', () => {
16
+ const qs = normaliseQuestions([
17
+ { question: 'Stack?', options: ['plain HTML', 'React'] },
18
+ { question: 'Imagery?', options: ['stock photos', 'you provide assets'] },
19
+ ]);
20
+ const out = formatAnswers(qs, { 'Stack?': 'plain HTML', 'Imagery?': 'stock photos' });
21
+ assert.equal(out, '• Stack?\n → plain HTML\n• Imagery?\n → stock photos');
22
+ });
23
+
24
+ test('an unanswered question is said to be unanswered, never silently dropped', () => {
25
+ // Silence must not read as agreement with the recommended option; the
26
+ // model is told to decide, which is the same instruction the timeout gives.
27
+ const qs = normaliseQuestions([{ question: 'Scope?', options: ['one page', 'multi-page'] }]);
28
+ assert.match(formatAnswers(qs, {}), /no answer — decide yourself/);
29
+ assert.match(formatAnswers(qs, { 'Scope?': ' ' }), /no answer — decide yourself/);
30
+ });
31
+
32
+ test('options may be strings or {label, hint}, and are trimmed', () => {
33
+ const [q] = normaliseQuestions([{
34
+ question: ' Stack? ',
35
+ options: [' plain HTML ', { label: 'React', hint: 'needs a build step' }, { label: ' ' }, 42],
36
+ multi: true,
37
+ }]);
38
+ assert.equal(q.question, 'Stack?');
39
+ // A string option carries no hint key at all — not `hint: undefined` —
40
+ // so the JSON the picker receives is exactly as small as what was sent.
41
+ assert.deepEqual(q.options, [
42
+ { label: 'plain HTML' },
43
+ { label: 'React', hint: 'needs a build step' },
44
+ ]);
45
+ assert.equal(q.multi, true);
46
+ });
47
+
48
+ test('at most three questions and six options each, and a blank question is dropped', () => {
49
+ const qs = normaliseQuestions([
50
+ { question: 'a', options: ['1', '2', '3', '4', '5', '6', '7', '8'] },
51
+ { question: '', options: ['x'] },
52
+ { question: 'b', options: ['1'] },
53
+ { question: 'c', options: ['1'] },
54
+ { question: 'd', options: ['1'] },
55
+ ]);
56
+ // 'a', (blank dropped), 'b', 'c' — the fourth real question is past the cap
57
+ // because the cap applies to what the model sent, not to what survived.
58
+ assert.deepEqual(qs.map((q) => q.question), ['a', 'b']);
59
+ assert.equal(qs[0].options.length, 6);
60
+ });
61
+
62
+ test('non-array input yields no questions rather than throwing', () => {
63
+ assert.deepEqual(normaliseQuestions(undefined), []);
64
+ assert.deepEqual(normaliseQuestions('Stack?'), []);
65
+ assert.deepEqual(normaliseQuestions({ question: 'Stack?' }), []);
66
+ });
67
+
68
+ test('the ask timer fires once, cancel prevents it, and zero never arms', async () => {
69
+ let fired = 0;
70
+ armAskTimeout(15, () => fired++);
71
+ await sleep(60);
72
+ assert.equal(fired, 1);
73
+
74
+ const b = armAskTimeout(15, () => fired++);
75
+ b.cancel();
76
+ await sleep(40);
77
+ assert.equal(fired, 1, 'a cancelled ask must not fire');
78
+
79
+ const c = armAskTimeout(0, () => fired++);
80
+ await sleep(30);
81
+ assert.equal(fired, 1, '0 means never, not immediately');
82
+ c.cancel(); // must be safe on a timer that was never armed
83
+
84
+ armAskTimeout(NaN, () => fired++);
85
+ armAskTimeout(-5, () => fired++);
86
+ await sleep(30);
87
+ assert.equal(fired, 1, 'nonsense durations never arm either');
88
+ });
package/src/ask.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Asking a human — the small mechanics shared by every place Foreman does it.
3
+ *
4
+ * Three surfaces ask: the director's `ask_human`, a permission prompt, and
5
+ * the planner's `ask_user`. They differ in what they ask and what a good
6
+ * unattended default is; they must not differ in the mechanics, because the
7
+ * mechanics are where the rule lives: **every ask carries an unattended
8
+ * default.** Foreman is an autonomous orchestrator, and a question that
9
+ * blocks indefinitely turns a governance feature into an outage.
10
+ */
11
+
12
+ /**
13
+ * One structured question, the shape a picker renders.
14
+ *
15
+ * Options are what make a question fast to answer — a click instead of a
16
+ * sentence — and putting the recommended one first is what makes the common
17
+ * case one click. `multi` is for "which of these" questions; the default is a
18
+ * single choice because most genuinely blocking questions are forks.
19
+ */
20
+ export interface AskOption {
21
+ label: string;
22
+ /** One line under the label: what choosing this implies. */
23
+ hint?: string;
24
+ }
25
+
26
+ export interface AskQuestion {
27
+ question: string;
28
+ options: AskOption[];
29
+ multi?: boolean;
30
+ }
31
+
32
+ /** A batch of questions with one id, answered together. */
33
+ export interface PendingAsk {
34
+ id: string;
35
+ questions: AskQuestion[];
36
+ askedAt: number;
37
+ }
38
+
39
+ /** Answers keyed by question text; a multi answer joins its labels with ", ". */
40
+ export type AskAnswers = Record<string, string>;
41
+
42
+ /**
43
+ * Arms a timer that fires `onTimeout` once after `ms`, or never when `ms` is
44
+ * zero. Trivial on purpose: the rule "0 disables, otherwise it fires" is worth
45
+ * being explicit and testable, since forgetting the zero case would make a
46
+ * per-run override of "never time out" silently time out anyway.
47
+ */
48
+ export function armAskTimeout(ms: number, onTimeout: () => void): { cancel(): void } {
49
+ if (!Number.isFinite(ms) || ms <= 0) return { cancel() { /* never armed */ } };
50
+ let fired = false;
51
+ const t = setTimeout(() => { fired = true; onTimeout(); }, ms);
52
+ t.unref?.();
53
+ return { cancel() { if (!fired) clearTimeout(t); } };
54
+ }
55
+
56
+ /**
57
+ * Renders answers back to the agent as prose it cannot misread.
58
+ *
59
+ * The agent asked in structured form and gets a structured echo: question,
60
+ * arrow, answer. An unanswered question is said to be unanswered rather than
61
+ * omitted, so the agent does not assume silence meant agreement with its
62
+ * recommended option.
63
+ */
64
+ export function formatAnswers(questions: AskQuestion[], answers: AskAnswers): string {
65
+ return questions
66
+ .map((q) => {
67
+ const a = answers[q.question];
68
+ return `• ${q.question}\n → ${a && a.trim() ? a.trim() : '(no answer — decide yourself)'}`;
69
+ })
70
+ .join('\n');
71
+ }
72
+
73
+ /** Validates and normalises what a model sent to an ask tool. */
74
+ export function normaliseQuestions(raw: unknown): AskQuestion[] {
75
+ const arr = Array.isArray(raw) ? raw : [];
76
+ const out: AskQuestion[] = [];
77
+ for (const q of arr.slice(0, 3) as Array<Record<string, unknown>>) {
78
+ const question = typeof q?.question === 'string' ? q.question.trim() : '';
79
+ if (!question) continue;
80
+ const options: AskOption[] = [];
81
+ for (const o of (Array.isArray(q.options) ? q.options : []).slice(0, 6)) {
82
+ if (typeof o === 'string' && o.trim()) options.push({ label: o.trim() });
83
+ else if (o && typeof o === 'object' && typeof (o as AskOption).label === 'string') {
84
+ const { label, hint } = o as AskOption;
85
+ if (label.trim()) options.push({ label: label.trim(), hint: typeof hint === 'string' ? hint : undefined });
86
+ }
87
+ }
88
+ // A question with fewer than two options is not a choice; it is a prompt
89
+ // for free text, and the picker still offers "something else", so it is
90
+ // kept rather than rejected — but a model that sends none is told so by
91
+ // the tool's own validation upstream.
92
+ out.push({ question, options, multi: q.multi === true });
93
+ }
94
+ return out;
95
+ }
@@ -0,0 +1,33 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { mkdtemp, readFile } from 'node:fs/promises';
6
+ import { attachmentLines, safeName, saveAttachments } from './attachments.js';
7
+
8
+ test('safeName keeps the extension and drops what a shell or a path would choke on', () => {
9
+ assert.equal(safeName('my screenshot (1).png'), 'my-screenshot-1-.png'.replace('-1-.png', '-1-.png'));
10
+ assert.equal(safeName('../../etc/passwd'), 'passwd');
11
+ assert.equal(safeName(''), 'file');
12
+ assert.equal(safeName('..'), 'file');
13
+ });
14
+
15
+ test('saveAttachments writes under .foreman/attachments and reports project-relative paths', async () => {
16
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'attach-'));
17
+ const saved = await saveAttachments(dir, [
18
+ { name: 'spec.md', data: Buffer.from('# spec').toString('base64') },
19
+ { name: 'shot.png', data: Buffer.from([0x89, 0x50]).toString('base64') },
20
+ ], new Date(2026, 8, 5, 19, 44, 12));
21
+ assert.equal(saved.length, 2);
22
+ assert.equal(saved[0].path, '.foreman/attachments/20260905-194412-1-spec.md');
23
+ assert.equal(saved[1].path, '.foreman/attachments/20260905-194412-2-shot.png');
24
+ assert.equal(await readFile(path.join(dir, saved[0].path), 'utf8'), '# spec');
25
+ assert.match(attachmentLines(saved), /Attached files.*\n- \.foreman\/attachments\/20260905-194412-1-spec\.md\n- /s);
26
+ });
27
+
28
+ test('saveAttachments refuses empty, oversized and too many files with a plain message', async () => {
29
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'attach-'));
30
+ await assert.rejects(saveAttachments(dir, [{ name: 'x', data: '' }]), /is empty/);
31
+ await assert.rejects(saveAttachments(dir, Array.from({ length: 11 }, () => ({ name: 'x', data: 'AA==' }))), /at most 10/);
32
+ assert.deepEqual(await saveAttachments(dir, []), []);
33
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Files a person hands to the planner or the director: a screenshot of the
3
+ * bug, a spec, a CSV. They land in the project folder — `.foreman/attachments/`
4
+ * — because that is the one place both the planner (read-only) and the crew
5
+ * can reach without any new permission, and the message that carries them
6
+ * names the paths. The folder self-ignores in git, so nothing leaks into the
7
+ * user's history. Files, not blobs in a database: the deck lists them as
8
+ * artifacts, and the human can find them with a file manager.
9
+ */
10
+ import { mkdir, writeFile } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+
13
+ export const ATTACHMENTS_DIR = path.join('.foreman', 'attachments');
14
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
15
+ export const MAX_ATTACHMENTS = 10;
16
+
17
+ export interface IncomingAttachment { name: string; data: string /* base64 */; type?: string }
18
+ export interface SavedAttachment { path: string; size: number; name: string }
19
+
20
+ /** A file name the filesystem and a shell both accept, keeping the extension. */
21
+ export function safeName(name: string): string {
22
+ const base = path.basename(String(name || 'file')).replace(/[^\w.\-]+/g, '-').replace(/^-+|-+$/g, '');
23
+ return base && base !== '.' && base !== '..' ? base.slice(0, 120) : 'file';
24
+ }
25
+
26
+ /** `20260905-194412-` — sortable, unique enough for a human's pace, readable. */
27
+ function stamp(d = new Date()): string {
28
+ const p = (n: number, l = 2) => String(n).padStart(l, '0');
29
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
30
+ }
31
+
32
+ /**
33
+ * Writes the files under the folder and returns their project-relative
34
+ * paths. Throws with a plain message on anything a person can fix (too many,
35
+ * too big, empty), so the route can hand it back as a 400.
36
+ */
37
+ export async function saveAttachments(folder: string, files: IncomingAttachment[], now = new Date()): Promise<SavedAttachment[]> {
38
+ if (!Array.isArray(files) || files.length === 0) return [];
39
+ if (files.length > MAX_ATTACHMENTS) throw new Error(`at most ${MAX_ATTACHMENTS} files per message`);
40
+ const dir = path.join(folder, ATTACHMENTS_DIR);
41
+ await mkdir(dir, { recursive: true });
42
+ const out: SavedAttachment[] = [];
43
+ const prefix = stamp(now);
44
+ for (const [i, f] of files.entries()) {
45
+ if (!f || typeof f.data !== 'string') throw new Error('each file needs base64 data');
46
+ const buf = Buffer.from(f.data, 'base64');
47
+ if (buf.length === 0) throw new Error(`${f.name || 'a file'} is empty`);
48
+ if (buf.length > MAX_ATTACHMENT_BYTES) throw new Error(`${f.name || 'a file'} is over ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`);
49
+ const name = `${prefix}${files.length > 1 ? `-${i + 1}` : ''}-${safeName(f.name)}`;
50
+ await writeFile(path.join(dir, name), buf, { flag: 'wx' });
51
+ out.push({ path: path.posix.join('.foreman', 'attachments', name), size: buf.length, name: safeName(f.name) });
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** The lines appended to a message so an agent knows what came with it. */
57
+ export function attachmentLines(saved: SavedAttachment[]): string {
58
+ if (!saved.length) return '';
59
+ return '\n\nAttached files (in the project folder — read them):\n' + saved.map((s) => `- ${s.path}`).join('\n');
60
+ }
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { launchdPlist, servicePath, systemdUnit } from './cli.js';
4
+
5
+ test('launchdPlist: label, node + bin + start, env, keepalive, one log', () => {
6
+ const p = launchdPlist({ label: 'dev.foreman.server', node: '/usr/local/bin/node', bin: '/x/bin/foreman.mjs', home: '/Users/a/.foreman', logDir: '/Users/a/.foreman/logs', env: { PATH: '/a:/b', PORT: '4177', X: 'a<b&c' } });
7
+ assert.match(p, /<key>Label<\/key><string>dev\.foreman\.server<\/string>/);
8
+ assert.match(p, /<string>\/usr\/local\/bin\/node<\/string>\s*<string>\/x\/bin\/foreman\.mjs<\/string>\s*<string>start<\/string>/);
9
+ assert.match(p, /<key>KeepAlive<\/key><true\/>/);
10
+ assert.match(p, /<key>X<\/key><string>a&lt;b&amp;c<\/string>/);
11
+ assert.match(p, /server\.log/);
12
+ });
13
+
14
+ test('systemdUnit: restart always, env lines, default target', () => {
15
+ const u = systemdUnit({ node: '/usr/bin/node', bin: '/x/bin/foreman.mjs', home: '/home/a/.foreman', env: { PORT: '4177' } });
16
+ assert.match(u, /ExecStart=\/usr\/bin\/node \/x\/bin\/foreman\.mjs start/);
17
+ assert.match(u, /Restart=always/);
18
+ assert.match(u, /Environment=PORT=4177/);
19
+ assert.match(u, /WantedBy=default\.target/);
20
+ });
21
+
22
+ test('servicePath: node dir first, usual prefixes, no duplicates', () => {
23
+ const p = servicePath('/opt/homebrew/Cellar/node/26/bin/node', '/usr/bin:/bin').split(':');
24
+ assert.equal(p[0], '/opt/homebrew/Cellar/node/26/bin');
25
+ assert.ok(p.includes('/opt/homebrew/bin') && p.includes('/usr/local/bin'));
26
+ assert.equal(new Set(p).size, p.length);
27
+ });