@dfsramos/foreman 0.0.0-stage → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +94 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +85 -0
  43. package/dist/sources/git.js +431 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +210 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +279 -0
  59. package/public/ui.js +165 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The project lease (ADR 0001): one machine at a time polls a git-source project, dispatches its
3
+ * tasks and acts on its reviews. The lease is a small file on the task branch that names the
4
+ * holder and when the lease runs out; the holder renews it while it polls, and a machine that
5
+ * finds it lapsed takes it over. This module is the decision alone and touches no file; the git
6
+ * source reads and writes the lease and acts on what is decided here.
7
+ */
8
+ /** How long a lease lasts from the moment it is taken or renewed. */
9
+ export const LEASE_TTL_MS = 10 * 60_000;
10
+ /** The holder renews once less than this remains, so a poll that runs late still finds the lease its own. */
11
+ export const LEASE_RENEW_MS = LEASE_TTL_MS / 2;
12
+ /** A time as the files on the branch carry it: ISO 8601 in UTC, to the second. */
13
+ export const stamp = (date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z');
14
+ /** Milliseconds the lease has left at `now`; an `until` that does not read as a time counts as run out. */
15
+ const remaining = (lease, now) => {
16
+ const until = Date.parse(lease.until);
17
+ return Number.isNaN(until) ? 0 : until - now.getTime();
18
+ };
19
+ /** What `host` should do about the lease as it stands at `now`. A lease is lapsed from the second it names. */
20
+ export function decideLease(lease, host, now) {
21
+ if (lease === null)
22
+ return { kind: 'take', from: null };
23
+ const left = remaining(lease, now);
24
+ if (left <= 0)
25
+ return { kind: 'take', from: lease.holder };
26
+ if (lease.holder === host)
27
+ return { kind: 'hold', renew: left < LEASE_RENEW_MS };
28
+ return { kind: 'wait', holder: lease.holder, until: lease.until };
29
+ }
30
+ /**
31
+ * The lease to write for `host` at `now`: it runs `LEASE_TTL_MS` from now, and `since` is kept
32
+ * from our own unlapsed lease so that it says when this machine started polling, not when it
33
+ * last renewed.
34
+ */
35
+ export function nextLease(lease, host, now) {
36
+ const since = lease !== null && decideLease(lease, host, now).kind === 'hold' ? lease.since : stamp(now);
37
+ return { holder: host, since, until: stamp(new Date(now.getTime() + LEASE_TTL_MS)) };
38
+ }
@@ -0,0 +1,186 @@
1
+ import { createLinearClient } from '../linear/client.js';
2
+ import { createIssueApi } from '../linear/issues.js';
3
+ import { fetchWorkspace } from '../linear/workspace.js';
4
+ /** Only issues carrying this label are picked up, unless the project's label setting says otherwise. */
5
+ export const DEFAULT_LABEL = 'foreman';
6
+ /** A project saved before the label setting existed gets the default; an explicitly empty one means "any issue". */
7
+ const labelOf = (settings) => (settings.label ?? DEFAULT_LABEL).trim();
8
+ const names = (settings) => ({
9
+ target: settings.targetState ?? 'Todo',
10
+ inProgress: settings.inProgressState ?? 'In Progress',
11
+ success: settings.successState ?? 'In Review',
12
+ approval: settings.approvalState ?? 'Pending approval',
13
+ done: settings.doneState ?? 'Done',
14
+ });
15
+ /**
16
+ * A Linear project as a task source. Workflow states are looked up by name on every fetch, so a
17
+ * renamed or missing state is reported at the next poll rather than at some later claim.
18
+ */
19
+ export function createLinearSource(settings, api) {
20
+ const stateNames = names(settings);
21
+ let states = null;
22
+ const resolved = async () => {
23
+ states ??= await api.resolveStates(settings.teamId ?? '', stateNames);
24
+ return states;
25
+ };
26
+ const teamId = settings.teamId ?? '';
27
+ const projectId = settings.projectId ?? '';
28
+ // The review states are only looked up when the review loop needs them, so a team without
29
+ // them can still have new work picked up.
30
+ const stateId = async (name) => (await api.findStateIds(teamId, [name]))[name];
31
+ const inState = async (name) => api.fetchCandidates(teamId, projectId, await stateId(name), labelOf(settings) || undefined);
32
+ return {
33
+ async fetchCandidates() {
34
+ states = await api.resolveStates(settings.teamId ?? '', stateNames);
35
+ return api.fetchCandidates(settings.teamId ?? '', settings.projectId ?? '', states.targetId, labelOf(settings) || undefined);
36
+ },
37
+ async claim(task) {
38
+ await api.moveIssue(task.id, (await resolved()).inProgressId);
39
+ },
40
+ async complete(task) {
41
+ await api.moveIssue(task.id, (await resolved()).successId);
42
+ },
43
+ fetchInReview: () => inState(stateNames.success),
44
+ fetchAwaitingApproval: () => inState(stateNames.approval),
45
+ async approve(task) {
46
+ await api.moveIssue(task.id, await stateId(stateNames.approval));
47
+ },
48
+ async finish(task) {
49
+ await api.moveIssue(task.id, await stateId(stateNames.done));
50
+ },
51
+ async comment(task, body) {
52
+ await api.addComment(task.id, body);
53
+ },
54
+ describe: () => ({
55
+ waiting: stateNames.target,
56
+ claimed: stateNames.inProgress,
57
+ done: stateNames.success,
58
+ approval: stateNames.approval,
59
+ finished: stateNames.done,
60
+ }),
61
+ };
62
+ }
63
+ /** Turns a workspace listing into options for the team, project and state fields. */
64
+ export function workspaceOptions(teams) {
65
+ const states = [];
66
+ for (const team of teams) {
67
+ for (const name of team.states) {
68
+ const existing = states.find((option) => option.value === name);
69
+ if (existing)
70
+ existing.for.push(team.id);
71
+ else
72
+ states.push({ value: name, label: name, for: [team.id] });
73
+ }
74
+ }
75
+ const labels = [];
76
+ for (const team of teams) {
77
+ for (const name of team.labels) {
78
+ const existing = labels.find((option) => option.value.toLowerCase() === name.toLowerCase());
79
+ if (existing)
80
+ existing.for.push(team.id);
81
+ else
82
+ labels.push({ value: name, label: name, for: [team.id] });
83
+ }
84
+ }
85
+ return {
86
+ teamId: teams.map((team) => ({ value: team.id, label: `${team.name} (${team.key})` })),
87
+ projectId: teams.flatMap((team) => team.projects.map((project) => ({ value: project.id, label: project.name, for: [team.id] }))),
88
+ targetState: states,
89
+ inProgressState: states,
90
+ successState: states,
91
+ approvalState: states,
92
+ doneState: states,
93
+ label: labels,
94
+ };
95
+ }
96
+ export const linearSourceType = {
97
+ id: 'linear',
98
+ name: 'Linear',
99
+ fields: [
100
+ { key: 'apiKey', label: 'API key', type: 'secret', required: true, help: 'A Linear personal API key.' },
101
+ { key: 'teamId', label: 'Team', type: 'text', required: true, help: 'The team whose workflow states are used.', lookup: {} },
102
+ {
103
+ key: 'projectId',
104
+ label: 'Project',
105
+ type: 'text',
106
+ required: true,
107
+ help: 'The project issues are taken from.',
108
+ lookup: { dependsOn: 'teamId' },
109
+ },
110
+ {
111
+ key: 'targetState',
112
+ label: 'Target state',
113
+ type: 'text',
114
+ required: true,
115
+ default: 'Todo',
116
+ help: 'Issues are picked up from here.',
117
+ lookup: { dependsOn: 'teamId' },
118
+ },
119
+ {
120
+ key: 'inProgressState',
121
+ label: 'In-progress state',
122
+ type: 'text',
123
+ required: true,
124
+ default: 'In Progress',
125
+ help: 'Set on claim; kept on failure.',
126
+ lookup: { dependsOn: 'teamId' },
127
+ },
128
+ {
129
+ key: 'successState',
130
+ label: 'Review state',
131
+ type: 'text',
132
+ required: true,
133
+ default: 'In Review',
134
+ help: 'Set once the pull request is open. Foreman watches these for review feedback.',
135
+ lookup: { dependsOn: 'teamId' },
136
+ },
137
+ {
138
+ key: 'approvalState',
139
+ label: 'Approval state',
140
+ type: 'text',
141
+ required: true,
142
+ default: 'Pending approval',
143
+ help: 'Set once the review is positive: a person now approves and merges.',
144
+ lookup: { dependsOn: 'teamId' },
145
+ },
146
+ {
147
+ key: 'doneState',
148
+ label: 'Done state',
149
+ type: 'text',
150
+ required: true,
151
+ default: 'Done',
152
+ help: 'Set once the pull request is merged.',
153
+ lookup: { dependsOn: 'teamId' },
154
+ },
155
+ {
156
+ key: 'label',
157
+ label: 'Label',
158
+ type: 'text',
159
+ required: false,
160
+ default: DEFAULT_LABEL,
161
+ help: 'Only issues with this label are picked up. Empty picks up every issue in the target state.',
162
+ lookup: { dependsOn: 'teamId' },
163
+ },
164
+ ],
165
+ create(settings) {
166
+ return createLinearSource(settings, createIssueApi(createLinearClient(settings.apiKey ?? '')));
167
+ },
168
+ async check(settings) {
169
+ try {
170
+ const api = createIssueApi(createLinearClient(settings.apiKey ?? ''));
171
+ const states = await api.resolveStates(settings.teamId ?? '', names(settings));
172
+ const label = labelOf(settings);
173
+ const waiting = await api.fetchCandidates(settings.teamId ?? '', settings.projectId ?? '', states.targetId, label || undefined);
174
+ const count = waiting.length === 1 ? '1 issue is' : `${waiting.length} issues are`;
175
+ const labelled = label ? ` with the label "${label}"` : '';
176
+ return { ok: true, message: `Connected. All three states exist, and ${count} waiting in "${names(settings).target}"${labelled}.` };
177
+ }
178
+ catch (error) {
179
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
180
+ }
181
+ },
182
+ async lookup(settings) {
183
+ return workspaceOptions(await fetchWorkspace(createLinearClient(settings.apiKey ?? '')));
184
+ },
185
+ summarise: (settings) => `Linear · ${names(settings).target} → ${names(settings).inProgress} → ${names(settings).success}${labelOf(settings) ? ` · label "${labelOf(settings)}"` : ' · any label'}`,
186
+ };
@@ -0,0 +1,26 @@
1
+ /** Turns a listing of projects into options for the project, state and label fields. */
2
+ export function workspaceOptions(projects) {
3
+ const collect = (valuesOf) => {
4
+ const options = [];
5
+ for (const project of projects) {
6
+ for (const name of valuesOf(project)) {
7
+ const existing = options.find((option) => option.value.toLowerCase() === name.toLowerCase());
8
+ if (existing)
9
+ existing.for.push(project.id);
10
+ else
11
+ options.push({ value: name, label: name, for: [project.id] });
12
+ }
13
+ }
14
+ return options;
15
+ };
16
+ const states = collect((project) => project.states);
17
+ return {
18
+ projectId: projects.map((project) => ({ value: project.id, label: `${project.name} (${project.identifier})` })),
19
+ targetState: states,
20
+ inProgressState: states,
21
+ successState: states,
22
+ approvalState: states,
23
+ doneState: states,
24
+ label: collect((project) => project.labels),
25
+ };
26
+ }
@@ -0,0 +1,6 @@
1
+ import { gitSourceType } from './git.js';
2
+ import { kaneoSourceType } from './kaneo.js';
3
+ import { linearSourceType } from './linear.js';
4
+ /** Every kind of tracker foreman can take work from. */
5
+ export const SOURCE_TYPES = [gitSourceType, linearSourceType, kaneoSourceType];
6
+ export const getSourceType = (id, types = SOURCE_TYPES) => types.find((type) => type.id === id);
@@ -0,0 +1,210 @@
1
+ import { slug } from './branch.js';
2
+ export const TASK_STATES = ['todo', 'in_progress', 'in_review', 'pending_approval', 'done'];
3
+ /** A task file that does not follow the format; the message names the file. */
4
+ export class TaskFileError extends Error {
5
+ constructor(name, reason) {
6
+ super(`${name}: ${reason}`);
7
+ this.name = 'TaskFileError';
8
+ }
9
+ }
10
+ const TASK_NAME = /^(\d+)-([a-z0-9-]+)\.md$/;
11
+ const FRONTMATTER_KEY = /^[A-Za-z0-9_-]+$/;
12
+ const TITLE_LINE = /^# (.+)$/;
13
+ const LOG_HEADING = '## Log';
14
+ const LOG_ENTRY = /^### (\d{4}-\d{2}-\d{2}T\S+)$/;
15
+ /** The number and slug of a task file name, or null for any other name (other notes, dotfiles). */
16
+ export function parseTaskName(name) {
17
+ const match = TASK_NAME.exec(name);
18
+ if (!match)
19
+ return null;
20
+ return { number: Number(match[1]), slug: match[2] };
21
+ }
22
+ const stateOf = (name, value) => {
23
+ if (value === undefined || value === '')
24
+ return 'todo';
25
+ if (TASK_STATES.includes(value))
26
+ return value;
27
+ throw new TaskFileError(name, `unknown state "${value}"`);
28
+ };
29
+ const priorityOf = (name, value) => {
30
+ if (value === undefined || value === '')
31
+ return 0;
32
+ if (!/^[0-4]$/.test(value))
33
+ throw new TaskFileError(name, `priority "${value}" is not a whole number from 0 to 4`);
34
+ return Number(value);
35
+ };
36
+ /** Lines of a body without their carriage returns, so a file edited on Windows reads the same. */
37
+ const linesOf = (text) => text.split('\n').map((line) => line.replace(/\r$/, ''));
38
+ /**
39
+ * Splits the frontmatter from the body. There is frontmatter only when line 1 is `---`; it runs
40
+ * to the next `---` line, and every non-blank line in between is `key: value` with the value
41
+ * being plain text to the end of the line.
42
+ */
43
+ function splitFrontmatter(name, text) {
44
+ const lines = text.split('\n');
45
+ if (lines[0]?.replace(/\r$/, '') !== '---')
46
+ return { meta: {}, body: text };
47
+ const meta = {};
48
+ for (let i = 1; i < lines.length; i++) {
49
+ const line = lines[i].replace(/\r$/, '');
50
+ if (line === '---')
51
+ return { meta, body: lines.slice(i + 1).join('\n') };
52
+ if (line.trim() === '')
53
+ continue;
54
+ const colon = line.indexOf(':');
55
+ if (colon <= 0)
56
+ throw new TaskFileError(name, `frontmatter line ${i + 1} is not "key: value"`);
57
+ meta[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
58
+ }
59
+ throw new TaskFileError(name, 'frontmatter is not closed');
60
+ }
61
+ /** Where the body's title line and log heading are, as line indexes; the log index is the line count when there is none. */
62
+ function layoutOf(lines) {
63
+ const titleAt = lines.findIndex((line) => TITLE_LINE.test(line));
64
+ const logAt = lines.findIndex((line, index) => index > titleAt && line === LOG_HEADING);
65
+ return { titleAt, logAt: logAt === -1 ? lines.length : logAt };
66
+ }
67
+ /** The title and description a body gives, falling back to the file's slug for the title. */
68
+ function derive(body, fileSlug) {
69
+ const lines = linesOf(body);
70
+ const { titleAt, logAt } = layoutOf(lines);
71
+ const title = titleAt === -1 ? fileSlug.replace(/-/g, ' ') : TITLE_LINE.exec(lines[titleAt])[1].trim();
72
+ const description = lines.slice(titleAt + 1, logAt).join('\n').trim();
73
+ return { title, description: description === '' ? null : description };
74
+ }
75
+ /** Parses one task file. Throws `TaskFileError` for a name that is not a task name or a file that breaks the format. */
76
+ export function parseTaskFile(name, text) {
77
+ const parsed = parseTaskName(name);
78
+ if (!parsed)
79
+ throw new TaskFileError(name, 'file name is not <number>-<slug>.md');
80
+ const { meta, body } = splitFrontmatter(name, text);
81
+ return {
82
+ name,
83
+ number: parsed.number,
84
+ slug: parsed.slug,
85
+ state: stateOf(name, meta.state),
86
+ priority: priorityOf(name, meta.priority),
87
+ meta,
88
+ ...derive(body, parsed.slug),
89
+ body,
90
+ };
91
+ }
92
+ /** The frontmatter block (`state`, `priority`, then the other keys in `meta` order, empty values left out) followed by the body unchanged. */
93
+ export function serialiseTaskFile(file) {
94
+ const lines = ['---', `state: ${file.state}`, `priority: ${file.priority}`];
95
+ for (const [key, value] of Object.entries(file.meta)) {
96
+ if (key === 'state' || key === 'priority' || value === '')
97
+ continue;
98
+ lines.push(`${key}: ${value}`);
99
+ }
100
+ lines.push('---');
101
+ return `${lines.join('\n')}\n${file.body}`;
102
+ }
103
+ /**
104
+ * The file with frontmatter keys set, or removed when the value is null. A change to `state` or
105
+ * `priority` is validated and updates the typed field too. The body is untouched.
106
+ */
107
+ export function withMeta(file, changes) {
108
+ const meta = { ...file.meta };
109
+ for (const [key, value] of Object.entries(changes)) {
110
+ if (value === null) {
111
+ delete meta[key];
112
+ continue;
113
+ }
114
+ if (!FRONTMATTER_KEY.test(key))
115
+ throw new TaskFileError(file.name, `"${key}" is not a frontmatter key`);
116
+ if (/[\r\n]/.test(value))
117
+ throw new TaskFileError(file.name, `the value of "${key}" spans more than one line`);
118
+ meta[key] = value;
119
+ }
120
+ return { ...file, meta, state: stateOf(file.name, meta.state), priority: priorityOf(file.name, meta.priority) };
121
+ }
122
+ /** The entries under the `## Log` heading, in file order; empty when the body has no log. */
123
+ export function logEntriesOf(file) {
124
+ const lines = linesOf(file.body);
125
+ const { logAt } = layoutOf(lines);
126
+ const entries = [];
127
+ let current = null;
128
+ const close = () => {
129
+ if (current)
130
+ entries.push({ at: current.at, text: current.lines.join('\n').trim() });
131
+ };
132
+ for (const line of lines.slice(logAt + 1)) {
133
+ const match = LOG_ENTRY.exec(line);
134
+ if (match) {
135
+ close();
136
+ current = { at: match[1], lines: [] };
137
+ }
138
+ else if (current) {
139
+ current.lines.push(line);
140
+ }
141
+ }
142
+ close();
143
+ return entries;
144
+ }
145
+ /** The file with a log entry added at the end of the body, under a `## Log` heading that is added when missing. */
146
+ export function appendLog(file, at, text) {
147
+ const base = file.body.replace(/\s+$/, '');
148
+ const hasLog = linesOf(base).includes(LOG_HEADING);
149
+ const opened = hasLog ? base : base === '' ? LOG_HEADING : `${base}\n\n${LOG_HEADING}`;
150
+ const body = `${opened}\n\n### ${at}\n\n${text.trim()}\n`;
151
+ return { ...file, body, ...derive(body, file.slug) };
152
+ }
153
+ /**
154
+ * The file with a new title, description and priority. Only the title line and the description
155
+ * under it are rewritten: whatever precedes the title line, the `## Log` section and every other
156
+ * frontmatter key stay as they were. The file name is kept, so the identifier and the link to the
157
+ * file do not move when the title does.
158
+ */
159
+ export function withContent(file, input) {
160
+ const title = input.title.replace(/\s+/g, ' ').trim();
161
+ if (title === '')
162
+ throw new TaskFileError(file.name, 'a task needs a title');
163
+ const description = input.description?.trim() ?? '';
164
+ const lines = linesOf(file.body);
165
+ const { titleAt, logAt } = layoutOf(lines);
166
+ const before = titleAt === -1 ? [] : lines.slice(0, titleAt);
167
+ const after = logAt === lines.length ? [] : ['', ...lines.slice(logAt)];
168
+ const written = description === '' ? [`# ${title}`] : [`# ${title}`, '', ...description.split(/\r?\n/)];
169
+ // A body that began with the frontmatter's closing line keeps its leading blank line.
170
+ const body = [...(titleAt === -1 ? [''] : []), ...before, ...written, ...after, ...(after.length === 0 ? [''] : [])].join('\n');
171
+ return { ...withMeta(file, { priority: String(input.priority) }), body, ...derive(body, file.slug) };
172
+ }
173
+ /** A fresh `todo` file for a task, as the import and the console create them. */
174
+ export function newTaskFile(number, input) {
175
+ const title = input.title.replace(/\s+/g, ' ').trim();
176
+ const description = input.description?.trim() ?? '';
177
+ const body = description === '' ? `\n# ${title}\n` : `\n# ${title}\n\n${description}\n`;
178
+ const text = `---\nstate: todo\npriority: ${input.priority}\ncreated: ${input.created}\n---\n${body}`;
179
+ return parseTaskFile(taskFileName(number, title), text);
180
+ }
181
+ /** `<number>-<slug of the title>.md`; a title with nothing to slug gets `task` so the name still parses. */
182
+ export function taskFileName(number, title) {
183
+ return `${number}-${slug(title) || 'task'}.md`;
184
+ }
185
+ /** The human-facing key of a task, e.g. `FM-12`. */
186
+ export function identifierOf(prefix, number) {
187
+ return `${prefix}-${number}`;
188
+ }
189
+ /** The lease a file holds, or null when the file is missing or is not one JSON object with the three fields. */
190
+ export function parseLease(text) {
191
+ if (text === null)
192
+ return null;
193
+ let value;
194
+ try {
195
+ value = JSON.parse(text);
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
201
+ return null;
202
+ const { holder, since, until } = value;
203
+ if (typeof holder !== 'string' || typeof since !== 'string' || typeof until !== 'string')
204
+ return null;
205
+ return { holder, since, until };
206
+ }
207
+ /** One JSON object on one line with a trailing newline. */
208
+ export function serialiseLease(lease) {
209
+ return `${JSON.stringify({ holder: lease.holder, since: lease.since, until: lease.until })}\n`;
210
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@dfsramos/foreman",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Polling-and-dispatch daemon that hands tracker issues to coding-agent CLIs and follows their pull requests.",
5
+ "license": "MIT",
6
+ "author": "Daniel Ramos",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dfsramos/foreman.git"
10
+ },
11
+ "keywords": [
12
+ "claude-code",
13
+ "codex",
14
+ "agents",
15
+ "linear",
16
+ "kaneo",
17
+ "git",
18
+ "automation"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "foreman": "dist/cli.js",
23
+ "fm": "dist/cli.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "public",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "os": [
31
+ "linux",
32
+ "darwin"
33
+ ],
34
+ "engines": {
35
+ "node": ">=24"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "dev": "DATA_DIR=./data tsx watch --disable-warning=ExperimentalWarning src/cli.ts run",
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "prepack": "npm run build",
44
+ "start": "DATA_DIR=./data node --disable-warning=ExperimentalWarning dist/cli.js run",
45
+ "test": "vitest run",
46
+ "typecheck": "tsc --noEmit",
47
+ "lint": "eslint ."
48
+ },
49
+ "dependencies": {
50
+ "@fastify/static": "^10.1.5",
51
+ "dotenv": "^18.0.5",
52
+ "fastify": "^5.12.5",
53
+ "zod": "^4.6.5"
54
+ },
55
+ "devDependencies": {
56
+ "@eslint/js": "^10.0.1",
57
+ "@types/node": "^24.19.1",
58
+ "eslint": "^10.12.0",
59
+ "globals": "^17.13.0",
60
+ "tsx": "^4.23.15",
61
+ "typescript": "^6.0.3",
62
+ "typescript-eslint": "^8.71.1",
63
+ "vitest": "^5.0.3"
64
+ }
65
+ }