@dfsramos/foreman 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable changes to foreman are recorded here. The format follows [Keep a Cha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.0] - 2026-10-26
8
+
9
+ ### Added
10
+ - The Tasks tab can edit a task already on the branch: each row has an **Edit** button that loads its title, description and priority into the form and saves them as one commit (`Edit FM-12: <title>.`). The state, claim and log are kept, and the file keeps its name, so the identifier and the agent's branch do not change.
11
+
7
12
  ## [0.2.0] - 2026-10-26
8
13
 
9
14
  The first public release, installed with `npm install -g @dfsramos/foreman`.
package/README.md CHANGED
@@ -211,7 +211,7 @@ Foreman: picked up. Claude Code is working on it.
211
211
 
212
212
  **Using `main` instead.** Branch can be set to `main`, or any other existing branch, and the task files then live in that branch's tree; but every claim, comment, lease renewal and state move is then a commit on `main`, and every workflow with `on: push` and no branch filter runs on each of them. The orphan branch carries no workflow files, so GitHub Actions does not run on it; a CI system that triggers on every branch of the repository does, and needs a branch filter.
213
213
 
214
- **Tasks tab.** The console's **Tasks** tab lists a Git branch project's tasks grouped by state, shows which machine holds the project's lease, and has a form that creates a task file on the branch (one commit, `Add FM-13: <title>.`). It is the way to add a task from a machine that has no worktree on the task branch.
214
+ **Tasks tab.** The console's **Tasks** tab lists a Git branch project's tasks grouped by state, shows which machine holds the project's lease, and has a form that creates a task file on the branch (one commit, `Add FM-13: <title>.`) and an **Edit** button on each row that changes a task's title, description and priority (one commit, `Edit FM-13: <title>.`; the file keeps its name, state, claim and log). It is the way to add or change a task from a machine that has no worktree on the task branch.
215
215
 
216
216
  ## Importing from Linear
217
217
 
@@ -1,4 +1,4 @@
1
- import { createTask } from '../sources/git-import.js';
1
+ import { createTask, updateTask } from '../sources/git-import.js';
2
2
  import { createGitSource } from '../sources/git.js';
3
3
  const MAX_TITLE_LENGTH = 200;
4
4
  const PRIORITY_RANGE = { min: 0, max: 4 };
@@ -7,7 +7,7 @@ const messageOf = (error) => (error instanceof Error ? error.message : String(er
7
7
  /** A project's git source, opened for one request; the store behind it is one per repository and branch, shared with the runner's. */
8
8
  const gitProjectTasks = (dataDir) => (project) => {
9
9
  const source = createGitSource(project.sourceSettings, { repoPath: project.repoPath, dataDir });
10
- return { inspect: () => source.inspect(), create: (input) => createTask(source, input) };
10
+ return { inspect: () => source.inspect(), create: (input) => createTask(source, input), update: (id, input) => updateTask(source, id, input) };
11
11
  };
12
12
  /** A whole number in `range`, from a number or a string of digits as the console's select sends it; null otherwise. */
13
13
  const wholeNumber = (value, range) => {
@@ -38,7 +38,7 @@ function parseNewTask(input) {
38
38
  return { errors };
39
39
  return { task: { title, description: description === '' ? null : description, priority: priority } };
40
40
  }
41
- /** Routes that list a git-source project's task files with its lease, and add a task file to its branch. */
41
+ /** Routes that list a git-source project's task files with its lease, and add or edit a task file on its branch. */
42
42
  export function registerTaskRoutes(app, { db, dataDir, projectTasks = gitProjectTasks(dataDir) }) {
43
43
  /** The project's tasks, or why the request cannot have them. */
44
44
  const tasksOf = (id) => {
@@ -74,4 +74,21 @@ export function registerTaskRoutes(app, { db, dataDir, projectTasks = gitProject
74
74
  return reply.code(502).send({ error: messageOf(error) });
75
75
  }
76
76
  });
77
+ app.patch('/api/projects/:id/tasks/:taskId', async (request, reply) => {
78
+ const target = tasksOf(request.params.id);
79
+ if ('error' in target)
80
+ return reply.code(target.status).send({ error: target.error });
81
+ if (!/^\d+$/.test(request.params.taskId))
82
+ return reply.code(404).send({ error: 'Task not found' });
83
+ const parsed = parseNewTask(request.body);
84
+ if ('errors' in parsed)
85
+ return reply.code(400).send({ errors: parsed.errors });
86
+ try {
87
+ return await target.tasks.update(request.params.taskId, parsed.task);
88
+ }
89
+ catch (error) {
90
+ const message = messageOf(error);
91
+ return reply.code(/was not found in/.test(message) ? 404 : 502).send({ error: message });
92
+ }
93
+ });
77
94
  }
@@ -1,6 +1,6 @@
1
1
  import { importFooter } from '../imports/destination.js';
2
2
  import { createGitSource } from './git.js';
3
- import { identifierOf, newTaskFile, parseTaskName, serialiseTaskFile } from './taskfile.js';
3
+ import { identifierOf, newTaskFile, parseTaskFile, parseTaskName, serialiseTaskFile, withContent } from './taskfile.js';
4
4
  /** How often a create is retried when another machine takes the number it was about to use. */
5
5
  const MAX_ATTEMPTS = 5;
6
6
  /** A time as the files carry it: ISO 8601 in UTC, to the second. */
@@ -29,6 +29,31 @@ export async function createTask(source, input, now = () => new Date()) {
29
29
  }
30
30
  throw new Error(`Gave up after ${MAX_ATTEMPTS} attempts: the next task number in ${source.directory} kept being taken.`);
31
31
  }
32
+ /**
33
+ * Rewrites the title, description and priority of task `id` and pushes it under `Edit <identifier>: <title>.`.
34
+ * The file is read again on the synced tree inside the transaction, so a state change or a claim
35
+ * another machine pushed in between is kept and only the three fields are replaced. Throws when the
36
+ * task is not on the branch.
37
+ */
38
+ export async function updateTask(source, id, input) {
39
+ if (input.title.trim() === '')
40
+ throw new Error('A task needs a title.');
41
+ const store = await source.store();
42
+ const number = Number(id);
43
+ const identifier = identifierOf(source.prefix, number);
44
+ const name = (await source.readAll()).find((file) => file.number === number)?.name;
45
+ if (!name)
46
+ throw new Error(`Task ${identifier} was not found in ${source.directory}`);
47
+ const path = `${source.directory}/${name}`;
48
+ const title = input.title.replace(/\s+/g, ' ').trim();
49
+ await store.transact(`Edit ${identifier}: ${title}.`, (tree) => {
50
+ const text = tree.read(path);
51
+ if (text === null)
52
+ throw new Error(`Task ${identifier} was not found in ${source.directory}`);
53
+ return { [path]: serialiseTaskFile(withContent(parseTaskFile(name, text), input)) };
54
+ });
55
+ return { identifier, name };
56
+ }
32
57
  /**
33
58
  * A git source as the place imported tasks are copied into. A task file has no field for an
34
59
  * external id, so the description ends with a line naming the origin, and that line is what later
@@ -276,6 +276,7 @@ export function createGitSource(settings, context, deps = {}) {
276
276
  id: String(file.number),
277
277
  identifier: identifier(file.number),
278
278
  title: file.title,
279
+ description: file.description,
279
280
  state: file.state,
280
281
  priority: file.priority,
281
282
  claimedBy: metaOf(file, 'claimed_by'),
@@ -150,6 +150,26 @@ export function appendLog(file, at, text) {
150
150
  const body = `${opened}\n\n### ${at}\n\n${text.trim()}\n`;
151
151
  return { ...file, body, ...derive(body, file.slug) };
152
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
+ }
153
173
  /** A fresh `todo` file for a task, as the import and the console create them. */
154
174
  export function newTaskFile(number, input) {
155
175
  const title = input.title.replace(/\s+/g, ' ').trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfsramos/foreman",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Polling-and-dispatch daemon that hands tracker issues to coding-agent CLIs and follows their pull requests.",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Ramos",
package/public/style.css CHANGED
@@ -1586,7 +1586,7 @@ select.input {
1586
1586
  .task-head,
1587
1587
  .task-row {
1588
1588
  display: grid;
1589
- grid-template-columns: 84px minmax(0, 1.6fr) 96px minmax(0, 1fr) 120px;
1589
+ grid-template-columns: 84px minmax(0, 1.6fr) 96px minmax(0, 1fr) 120px 56px;
1590
1590
  gap: 12px;
1591
1591
  align-items: center;
1592
1592
  padding: 0 16px;
package/public/tasks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The Tasks tab: a git-source project's task files grouped by state, the machine that holds the
2
- // project's lease, and a form that adds a task as a new file on the branch. Everything is read
2
+ // project's lease, and a form that adds a task as a new file on the branch or edits one already there. Everything is read
3
3
  // from the branch as it is on the remote, from any machine and lease or not: only polling is
4
4
  // gated, so this is where a person sees which machine holds a project and which holds each task.
5
5
  // All text from the files is inserted as text nodes.
@@ -16,7 +16,8 @@ const STATES = [
16
16
  ['done', 'Done'],
17
17
  ];
18
18
 
19
- const emptyForm = () => ({ title: '', description: '', priority: '0', errors: {}, busy: false });
19
+ /** `editing` is the id of the task the form changes, or null when it adds a new one. */
20
+ const emptyForm = () => ({ editing: null, title: '', description: '', priority: '0', errors: {}, busy: false });
20
21
 
21
22
  const view = {
22
23
  projectId: null,
@@ -61,6 +62,8 @@ export function tasksTab(ctx) {
61
62
  view.data = null;
62
63
  view.error = null;
63
64
  view.notice = null;
65
+ // An edit belongs to the task of the project it was started on; a draft of a new task carries over.
66
+ if (view.form.editing) view.form = emptyForm();
64
67
  view.form.errors = {};
65
68
  }
66
69
 
@@ -150,7 +153,7 @@ export function tasksTab(ctx) {
150
153
  h('summary', null, h('span', { class: 'task-state' }, name), h('span', { class: 'task-count' }, String(tasks.length))),
151
154
  tasks.length === 0
152
155
  ? h('div', { class: 'task-empty' }, 'None.')
153
- : [h('div', { class: 'task-head' }, ...['Task', 'Title', 'Priority', 'Claimed by', 'Pull request'].map((label) => h('span', null, label))), tasks.map(row)],
156
+ : [h('div', { class: 'task-head' }, ...['Task', 'Title', 'Priority', 'Claimed by', 'Pull request', ''].map((label) => h('span', null, label))), tasks.map(row)],
154
157
  );
155
158
  // Folding is remembered across refreshes, not across visits to the tab.
156
159
  details.addEventListener('toggle', () => {
@@ -169,9 +172,19 @@ export function tasksTab(ctx) {
169
172
  h('span', null, PRIORITY_NAMES[task.priority] ?? '—'),
170
173
  task.claimedBy ? h('span', { class: 'ellipsis' }, task.claimedBy, h('span', { class: 'faint' }, ` since ${fmtDateTime(task.claimedAt)}`)) : h('span', { class: 'faint' }, '—'),
171
174
  task.pr ? externalLink(task.pr, prLabel(task.pr)) : h('span', { class: 'faint' }, '—'),
175
+ button('Edit', { onclick: () => edit(task) }),
172
176
  );
173
177
  }
174
178
 
179
+ /** Loads a task into the form; the form takes the place of the "new task" one until it is saved or cancelled. */
180
+ function edit(task) {
181
+ view.form = { ...emptyForm(), editing: task.id, title: task.title, description: task.description ?? '', priority: String(task.priority) };
182
+ view.notice = null;
183
+ ctx.rerender();
184
+ document.getElementById('task-title')?.scrollIntoView({ block: 'center' });
185
+ document.getElementById('task-title')?.focus();
186
+ }
187
+
175
188
  /* ---------- new task ---------- */
176
189
 
177
190
  function formCard() {
@@ -208,45 +221,58 @@ export function tasksTab(ctx) {
208
221
  form.errors = {};
209
222
  view.notice = null;
210
223
  ctx.rerender();
211
- const response = await api('POST', `/api/projects/${view.projectId}/tasks`, {
212
- title: form.title,
213
- description: form.description,
214
- priority: Number(form.priority),
215
- });
224
+ const editing = form.editing;
225
+ const body = { title: form.title, description: form.description, priority: Number(form.priority) };
226
+ const response = editing
227
+ ? await api('PATCH', `/api/projects/${view.projectId}/tasks/${editing}`, body)
228
+ : await api('POST', `/api/projects/${view.projectId}/tasks`, body);
216
229
  if (view.form !== form) return;
217
230
  form.busy = false;
218
231
  if (response.ok) {
219
- view.notice = { tone: 'ok', title: `Added ${response.data.identifier}`, text: `${response.data.name} is on the branch, waiting as todo.` };
232
+ view.notice = editing
233
+ ? { tone: 'ok', title: `Saved ${response.data.identifier}`, text: `${response.data.name} is updated on the branch.` }
234
+ : { tone: 'ok', title: `Added ${response.data.identifier}`, text: `${response.data.name} is on the branch, waiting as todo.` };
220
235
  view.form = emptyForm();
221
236
  load();
222
237
  } else if (response.data?.errors) {
223
238
  form.errors = response.data.errors;
224
- if (form.errors._) view.notice = { tone: 'fail', title: 'Not added', text: form.errors._ };
239
+ if (form.errors._) view.notice = { tone: 'fail', title: editing ? 'Not saved' : 'Not added', text: form.errors._ };
225
240
  } else {
226
- view.notice = { tone: 'fail', title: 'Not added', text: response.data?.error ?? `Request failed (${response.status})` };
241
+ view.notice = { tone: 'fail', title: editing ? 'Not saved' : 'Not added', text: response.data?.error ?? `Request failed (${response.status})` };
227
242
  }
228
243
  ctx.rerender();
229
244
  }
230
245
 
246
+ function cancel() {
247
+ view.form = emptyForm();
248
+ ctx.rerender();
249
+ }
250
+
231
251
  showNotice();
232
252
  return h(
233
253
  'section',
234
254
  { class: 'card' },
235
- h('div', { class: 'card-head' }, h('h2', null, 'New task'), h('span', { class: 'sub' }, 'One todo file, pushed as one commit')),
255
+ h(
256
+ 'div',
257
+ { class: 'card-head' },
258
+ h('h2', null, form.editing ? 'Edit task' : 'New task'),
259
+ h('span', { class: 'sub' }, form.editing ? 'Rewrites the title, description and priority; state and log stay' : 'One todo file, pushed as one commit'),
260
+ ),
236
261
  noticeLine,
237
262
  h(
238
263
  'div',
239
264
  { class: 'fields' },
240
- field('Title', 'title', 'task-title', title, 'Names the file: <number>-<slug of the title>.md.'),
265
+ field('Title', 'title', 'task-title', title, form.editing ? 'The file name keeps the old title.' : 'Names the file: <number>-<slug of the title>.md.'),
241
266
  field('Priority', 'priority', 'task-priority', priority, 'Urgent tasks are picked up first.'),
242
267
  field('Description', 'description', 'task-description', description, 'Markdown. What the agent is told to do.', true),
243
268
  ),
244
269
  h(
245
270
  'div',
246
271
  { class: 'save-bar' },
247
- h('span', { class: 'note' }, 'Needs no lease: any machine can add a task.'),
272
+ h('span', { class: 'note' }, form.editing ? 'The file keeps its name, so the identifier and branch do not change.' : 'Needs no lease: any machine can add a task.'),
248
273
  h('span', { class: 'spacer' }),
249
- button(form.busy ? 'Adding…' : 'Add task', { variant: 'primary', disabled: form.busy || !view.data, onclick: () => void submit() }),
274
+ form.editing ? button('Cancel', { disabled: form.busy, onclick: cancel }) : null,
275
+ button(form.busy ? (form.editing ? 'Saving…' : 'Adding…') : form.editing ? 'Save changes' : 'Add task', { variant: 'primary', disabled: form.busy || !view.data, onclick: () => void submit() }),
250
276
  ),
251
277
  );
252
278
  }