jackherd 1.8.0 → 1.9.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 +39 -0
- package/INSTALL.md +2 -0
- package/README.md +41 -60
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +22 -3
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
- package/package.json +3 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/read-config.ts +5 -1
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +12 -6
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +33 -1
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +5 -5
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type PreviewPage = { title: string; html: string }
|
|
2
|
+
|
|
3
|
+
const escapeHtml = (text: string): string =>
|
|
4
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
5
|
+
|
|
6
|
+
const PREVIEW_STYLE = {
|
|
7
|
+
github: {
|
|
8
|
+
name: 'GitHub',
|
|
9
|
+
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
|
|
10
|
+
size: '16px',
|
|
11
|
+
line: '1.5',
|
|
12
|
+
},
|
|
13
|
+
npm: { name: 'npm', font: '"Source Sans Pro", "Source Sans 3", sans-serif', size: '18px', line: '29.7px' },
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
const FONT_LINK = 'https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;600;700&display=swap'
|
|
17
|
+
|
|
18
|
+
const STYLE = `
|
|
19
|
+
:root { color-scheme: light dark; }
|
|
20
|
+
body { margin: 0; font: 14px system-ui, sans-serif; background: #6e7781; }
|
|
21
|
+
header { display: flex; gap: 16px; align-items: center; padding: 8px 16px; background: #24292f; color: #fff; }
|
|
22
|
+
header label { display: flex; gap: 4px; align-items: center; }
|
|
23
|
+
main { display: flex; gap: 16px; align-items: flex-start; padding: 16px; }
|
|
24
|
+
section.pane { flex: 1 1 0; min-width: 0; border-radius: 6px; overflow: hidden; }
|
|
25
|
+
body.phone section.pane { flex: 0 0 390px; }
|
|
26
|
+
.pane-bar { display: flex; justify-content: space-between; padding: 4px 12px; background: #d0d7de; color: #1f2328; }
|
|
27
|
+
.pane[data-theme="dark"] .pane-bar { background: #30363d; color: #e6edf3; }
|
|
28
|
+
.doc { padding: 32px; --fg: #1f2328; --bg: #fff; --muted: #656d76; --line: #d0d7de; --code: #f6f8fa; --link: #0969da; color: var(--fg); background: var(--bg); }
|
|
29
|
+
.pane[data-theme="dark"] .doc { --fg: #e6edf3; --bg: #0d1117; --muted: #8d96a0; --line: #30363d; --code: #161b22; --link: #4493f8; }
|
|
30
|
+
.pane[data-theme="light"] .only-dark, .pane[data-theme="dark"] .only-light { display: none; }
|
|
31
|
+
.doc a { color: var(--link); }
|
|
32
|
+
.doc h1, .doc h2 { padding-bottom: .3em; border-bottom: 1px solid var(--line); }
|
|
33
|
+
.doc h1, .doc h2, .doc h3, .doc h4 { margin: 24px 0 16px; font-weight: 600; line-height: 1.25; }
|
|
34
|
+
.doc p, .doc ul, .doc ol, .doc table, .doc pre, .doc details { margin: 0 0 16px; }
|
|
35
|
+
.doc img { max-width: 100%; }
|
|
36
|
+
.doc code { padding: .2em .4em; border-radius: 6px; background: var(--code); font-size: 85%; }
|
|
37
|
+
.doc pre { padding: 16px; overflow: auto; border-radius: 6px; background: var(--code); }
|
|
38
|
+
.doc pre code { padding: 0; background: none; }
|
|
39
|
+
.doc table { border-collapse: collapse; display: block; overflow: auto; }
|
|
40
|
+
.doc th, .doc td { padding: 6px 13px; border: 1px solid var(--line); }
|
|
41
|
+
.doc tr:nth-child(2n) { background: var(--code); }
|
|
42
|
+
.doc blockquote { margin: 0 0 16px; padding: 0 1em; color: var(--muted); border-left: .25em solid var(--line); }
|
|
43
|
+
.doc hr { border: 0; height: 1px; background: var(--line); }
|
|
44
|
+
.doc .anchor { display: none; }
|
|
45
|
+
@media (max-width: 900px) { main { flex-direction: column; } }
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
const SCRIPT = `
|
|
49
|
+
for (const button of document.querySelectorAll('[data-theme-for]')) {
|
|
50
|
+
button.addEventListener('click', () => {
|
|
51
|
+
const pane = document.getElementById(button.dataset.themeFor)
|
|
52
|
+
pane.dataset.theme = pane.dataset.theme === 'dark' ? 'light' : 'dark'
|
|
53
|
+
button.textContent = pane.dataset.theme
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
document.getElementById('phone').addEventListener('change', (event) => document.body.classList.toggle('phone', event.target.checked))
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
const paneOf = (id: keyof typeof PREVIEW_STYLE, html: string): string => {
|
|
60
|
+
const { name, font, size, line } = PREVIEW_STYLE[id]
|
|
61
|
+
return `<section class="pane" id="${id}" data-theme="light">
|
|
62
|
+
<div class="pane-bar"><b>${name} style</b><button data-theme-for="${id}">light</button></div>
|
|
63
|
+
<article class="doc" style="font: ${size}/${line} ${font}">${html}</article>
|
|
64
|
+
</section>`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The preview page: the rendered README twice, in GitHub style (16 px system font) and in npm style (Source Sans
|
|
69
|
+
* 18 px, line height 29.7 px), each pane with its own light/dark switch, and a switch to phone width.
|
|
70
|
+
*/
|
|
71
|
+
export const previewPage = ({ title, html }: PreviewPage): string => `<!doctype html>
|
|
72
|
+
<html lang="en">
|
|
73
|
+
<head>
|
|
74
|
+
<meta charset="utf-8">
|
|
75
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
|
+
<title>${escapeHtml(title)} (preview)</title>
|
|
77
|
+
<link rel="stylesheet" href="${FONT_LINK}">
|
|
78
|
+
<style>${STYLE}</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<header><b>${escapeHtml(title)}</b><label><input type="checkbox" id="phone"> phone width</label></header>
|
|
82
|
+
<main>
|
|
83
|
+
${paneOf('github', html)}
|
|
84
|
+
${paneOf('npm', html)}
|
|
85
|
+
</main>
|
|
86
|
+
<script>${SCRIPT}</script>
|
|
87
|
+
</body>
|
|
88
|
+
</html>
|
|
89
|
+
`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GhAnswer, GhOptions } from '../watch/gh'
|
|
2
|
+
|
|
3
|
+
export type GhRunner = (args: string[], cwd: string, options?: GhOptions) => Promise<GhAnswer>
|
|
4
|
+
|
|
5
|
+
/** Names the `owner/repo` of a git remote address (ssh or https, `.git` optional); null for anything else. */
|
|
6
|
+
export const repositorySlugOf = (remote: string): string | null =>
|
|
7
|
+
/^git@[^:]+:([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote.trim())?.[1] ??
|
|
8
|
+
/^https?:\/\/[^/]+\/([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote.trim())?.[1] ??
|
|
9
|
+
null
|
|
10
|
+
|
|
11
|
+
const MISSING_GH =
|
|
12
|
+
'the GitHub CLI `gh` is not installed: install it from https://cli.github.com, then run `gh auth login`'
|
|
13
|
+
const NOT_SIGNED_IN = 'the GitHub CLI is not signed in: run `gh auth login`, then try again'
|
|
14
|
+
|
|
15
|
+
/** The reason a `gh` call failed, with the step that fixes it when it is the missing CLI or the missing login. */
|
|
16
|
+
export const ghFailure = (error: string): string => {
|
|
17
|
+
if (/ENOENT|not found|not installed|No such file/i.test(error)) return MISSING_GH
|
|
18
|
+
if (/gh auth login|HTTP 401|not logged|authentication/i.test(error)) return NOT_SIGNED_IN
|
|
19
|
+
return `GitHub's markdown API failed: ${error}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The HTML GitHub renders for `markdown` (flavor `gfm`, with the repository as context when known). The request body
|
|
24
|
+
* goes in through standard input: a long document as an argument exceeds the operating system's limit (`E2BIG`).
|
|
25
|
+
* Throws with the missing step.
|
|
26
|
+
*/
|
|
27
|
+
export const renderMarkdown = async (
|
|
28
|
+
markdown: string,
|
|
29
|
+
repositorySlug: string | null,
|
|
30
|
+
cwd: string,
|
|
31
|
+
gh: GhRunner,
|
|
32
|
+
): Promise<string> => {
|
|
33
|
+
const body = JSON.stringify({
|
|
34
|
+
text: markdown,
|
|
35
|
+
mode: 'gfm',
|
|
36
|
+
...(repositorySlug === null ? {} : { context: repositorySlug }),
|
|
37
|
+
})
|
|
38
|
+
const answer = await gh(['api', '/markdown', '--input', '-'], cwd, { stdin: body })
|
|
39
|
+
if (!answer.ok) throw new Error(ghFailure(answer.error))
|
|
40
|
+
return answer.stdout
|
|
41
|
+
}
|
package/src/settled/board-row.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
1
2
|
import { sectionStart } from '../plan-format/markdown'
|
|
2
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
4
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
@@ -50,8 +51,6 @@ export const isInSettledSection = (lines: readonly string[], index: number): boo
|
|
|
50
51
|
return above !== undefined && headings.includes(above.trim())
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
54
|
-
|
|
55
54
|
/** Local time as the board writes it: `DD.MM. hh:mm` in German, `YYYY-MM-DD hh:mm` in English. */
|
|
56
55
|
export const settledTime = (time: Date, language: PlanLanguage): string =>
|
|
57
56
|
BOARD_TEXTS[language].time({
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const RECORD_FILE = /^[0-9a-f]{12}\.json$/
|
|
5
|
+
const MAX_RECORD_BYTES = 16 * 1024
|
|
6
|
+
const PRIVATE_FILE = 0o600
|
|
7
|
+
const PRIVATE_DIRECTORY = 0o700
|
|
8
|
+
|
|
9
|
+
const recordAt = <T>(path: string, isRecordOfKind: (value: unknown) => value is T): T[] => {
|
|
10
|
+
try {
|
|
11
|
+
const entry = lstatSync(path)
|
|
12
|
+
if (!entry.isFile() || entry.size > MAX_RECORD_BYTES) return []
|
|
13
|
+
const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
14
|
+
return isRecordOfKind(record) ? [record] : []
|
|
15
|
+
} catch {
|
|
16
|
+
return []
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every well-formed `<id>.json` record in `directory` (a symbolic link, an oversized file and one the guard refuses are
|
|
22
|
+
* skipped), ascending by `sortKey`; nothing when the directory is missing or unreadable.
|
|
23
|
+
*/
|
|
24
|
+
export const readRecords = <T>(
|
|
25
|
+
directory: string,
|
|
26
|
+
isRecordOfKind: (value: unknown) => value is T,
|
|
27
|
+
sortKey: (record: T) => number,
|
|
28
|
+
): T[] => {
|
|
29
|
+
try {
|
|
30
|
+
return readdirSync(directory)
|
|
31
|
+
.filter((name) => RECORD_FILE.test(name))
|
|
32
|
+
.flatMap((name) => recordAt(join(directory, name), isRecordOfKind))
|
|
33
|
+
.sort((left, right) => sortKey(left) - sortKey(right))
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Writes `<id>.json` through a temporary file, `0600` in a `0700` directory, so a reader never sees half of it. */
|
|
40
|
+
export const writeRecordAtomically = <T extends { id: string }>(directory: string, record: T): void => {
|
|
41
|
+
mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
|
|
42
|
+
chmodSync(directory, PRIVATE_DIRECTORY)
|
|
43
|
+
const path = join(directory, `${record.id}.json`)
|
|
44
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
45
|
+
writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: PRIVATE_FILE })
|
|
46
|
+
chmodSync(temporary, PRIVATE_FILE)
|
|
47
|
+
renameSync(temporary, path)
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cleanAgentName } from './text'
|
|
2
|
+
|
|
3
|
+
const WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)/
|
|
4
|
+
|
|
5
|
+
const MAIN_AGENT = 'main'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Who is reporting: the herdr pane when `HERDR_PANE_ID` is set (an agent has its own pane), else the worktree the
|
|
9
|
+
* directory lies in (`.claude/worktrees/<name>`), else `main`.
|
|
10
|
+
*/
|
|
11
|
+
export const agentIdentity = (environment: Record<string, string | undefined>, cwd: string): string => {
|
|
12
|
+
const pane = cleanAgentName(environment.HERDR_PANE_ID ?? '')
|
|
13
|
+
if (pane) return `pane ${pane}`
|
|
14
|
+
return WORKTREE.exec(cwd)?.[1] ?? MAIN_AGENT
|
|
15
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { readTailLines } from '../context/session-log'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { claudeSignalsOf } from './claude-signals'
|
|
6
|
+
import { cleanAgentName } from './text'
|
|
7
|
+
import type { TaskSignal } from './types'
|
|
8
|
+
|
|
9
|
+
const SUBAGENT_LOG = /^(agent-[A-Za-z0-9]+)\.jsonl$/
|
|
10
|
+
|
|
11
|
+
/** How much of the end of a log is read by default: sessions grow to hundreds of megabytes. */
|
|
12
|
+
const SIGNAL_TAIL_BYTES = 8 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
export type ReadOptions = {
|
|
15
|
+
/** How many bytes from the end of each log are read; a task created before that is unknown, so it gives no signal. */
|
|
16
|
+
tailBytes?: number
|
|
17
|
+
/** Subagent logs last written before this (epoch milliseconds) are left out. */
|
|
18
|
+
notOlderThan?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const linesOf = (path: string, tailBytes: number): string[] => {
|
|
22
|
+
try {
|
|
23
|
+
return readTailLines(path, tailBytes)
|
|
24
|
+
} catch {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The subagent's own description when its `.meta.json` has one, else the log's name. */
|
|
30
|
+
const agentNameOf = (directory: string, stem: string): string => {
|
|
31
|
+
try {
|
|
32
|
+
const meta: unknown = JSON.parse(readFileSync(join(directory, `${stem}.meta.json`), 'utf8'))
|
|
33
|
+
const description = isRecord(meta) ? meta.description : null
|
|
34
|
+
const name = typeof description === 'string' ? cleanAgentName(description) : ''
|
|
35
|
+
return name || stem
|
|
36
|
+
} catch {
|
|
37
|
+
return stem
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isRecent = (path: string, notOlderThan: number): boolean => {
|
|
42
|
+
try {
|
|
43
|
+
return statSync(path).mtimeMs >= notOlderThan
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const subagentSignals = (directory: string, tailBytes: number, notOlderThan: number): TaskSignal[] => {
|
|
50
|
+
try {
|
|
51
|
+
return readdirSync(directory)
|
|
52
|
+
.sort()
|
|
53
|
+
.flatMap((name) => {
|
|
54
|
+
const stem = SUBAGENT_LOG.exec(name)?.[1]
|
|
55
|
+
const path = join(directory, name)
|
|
56
|
+
return stem && isRecent(path, notOlderThan)
|
|
57
|
+
? claudeSignalsOf(linesOf(path, tailBytes), agentNameOf(directory, stem))
|
|
58
|
+
: []
|
|
59
|
+
})
|
|
60
|
+
} catch {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The tasks in progress in a Claude Code session log and in every `<session>/subagents/agent-*.jsonl` beside it. */
|
|
66
|
+
export const readClaudeSignals = (
|
|
67
|
+
sessionLogPath: string,
|
|
68
|
+
{ tailBytes = SIGNAL_TAIL_BYTES, notOlderThan = 0 }: ReadOptions = {},
|
|
69
|
+
): TaskSignal[] => [
|
|
70
|
+
...claudeSignalsOf(linesOf(sessionLogPath, tailBytes), 'session'),
|
|
71
|
+
...subagentSignals(join(sessionLogPath.replace(/\.jsonl$/, ''), 'subagents'), tailBytes, notOlderThan),
|
|
72
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { parseLogEntry } from '../context/session-log'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
import { cleanTaskText } from './text'
|
|
4
|
+
import type { TaskSignal } from './types'
|
|
5
|
+
|
|
6
|
+
type Block = Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
type TaskDraft = { subject?: unknown; activeForm?: unknown; description?: unknown }
|
|
9
|
+
|
|
10
|
+
type TrackedTask = { text: string; startedAt: number | null }
|
|
11
|
+
|
|
12
|
+
type Tracker = {
|
|
13
|
+
pending: Map<string, TaskDraft>
|
|
14
|
+
tasks: Map<string, TrackedTask>
|
|
15
|
+
todos: Map<string, number>
|
|
16
|
+
cwd: string
|
|
17
|
+
time: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TASK_STATUS = {
|
|
21
|
+
pending: 'pending',
|
|
22
|
+
inProgress: 'in_progress',
|
|
23
|
+
completed: 'completed',
|
|
24
|
+
deleted: 'deleted',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
const recordsIn = (values: unknown): Block[] => (Array.isArray(values) ? values.filter(isRecord) : [])
|
|
28
|
+
|
|
29
|
+
const firstText = (...candidates: unknown[]): string =>
|
|
30
|
+
candidates.map((candidate) => (typeof candidate === 'string' ? cleanTaskText(candidate) : '')).find(Boolean) ?? ''
|
|
31
|
+
|
|
32
|
+
const blocksOf = (entry: Record<string, unknown>): Block[] => {
|
|
33
|
+
return recordsIn(isRecord(entry.message) ? entry.message.content : undefined)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const createdIdOf = (entry: Record<string, unknown>, block: Block): string | null => {
|
|
37
|
+
const task = isRecord(entry.toolUseResult) ? entry.toolUseResult.task : undefined
|
|
38
|
+
const id = isRecord(task) ? task.id : undefined
|
|
39
|
+
if (typeof id === 'string' || typeof id === 'number') return String(id)
|
|
40
|
+
const match = typeof block.content === 'string' ? /^Task #(\S+) created/.exec(block.content) : null
|
|
41
|
+
return match ? match[1] : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const onResult = (tracker: Tracker, entry: Record<string, unknown>, block: Block): void => {
|
|
45
|
+
const draft = typeof block.tool_use_id === 'string' ? tracker.pending.get(block.tool_use_id) : undefined
|
|
46
|
+
if (!draft) return
|
|
47
|
+
const id = createdIdOf(entry, block)
|
|
48
|
+
if (id === null) return
|
|
49
|
+
tracker.tasks.set(id, { text: firstText(draft.subject, draft.activeForm, draft.description), startedAt: null })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const onTaskUpdate = (tracker: Tracker, input: Record<string, unknown>): void => {
|
|
53
|
+
const id = typeof input.taskId === 'string' || typeof input.taskId === 'number' ? String(input.taskId) : null
|
|
54
|
+
const task = id === null ? undefined : tracker.tasks.get(id)
|
|
55
|
+
if (id === null || !task) return
|
|
56
|
+
if (input.status === TASK_STATUS.completed || input.status === TASK_STATUS.deleted) {
|
|
57
|
+
tracker.tasks.delete(id)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
const renamed = firstText(input.subject)
|
|
61
|
+
if (renamed) task.text = renamed
|
|
62
|
+
if (input.status === TASK_STATUS.inProgress && task.startedAt === null) task.startedAt = tracker.time
|
|
63
|
+
if (input.status === TASK_STATUS.pending) task.startedAt = null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const onTodoWrite = (tracker: Tracker, input: Record<string, unknown>): void => {
|
|
67
|
+
const todos = recordsIn(input.todos)
|
|
68
|
+
const active = new Map<string, number>()
|
|
69
|
+
for (const todo of todos) {
|
|
70
|
+
const text = todo.status === TASK_STATUS.inProgress ? firstText(todo.content, todo.activeForm) : ''
|
|
71
|
+
if (text) active.set(text, tracker.todos.get(text) ?? tracker.time)
|
|
72
|
+
}
|
|
73
|
+
tracker.todos = active
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const onToolUse = (tracker: Tracker, block: Block): void => {
|
|
77
|
+
const input = block.input
|
|
78
|
+
if (!isRecord(input)) return
|
|
79
|
+
if (block.name === 'TaskCreate' && typeof block.id === 'string') tracker.pending.set(block.id, input)
|
|
80
|
+
if (block.name === 'TaskUpdate') onTaskUpdate(tracker, input)
|
|
81
|
+
if (block.name === 'TodoWrite') onTodoWrite(tracker, input)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
|
|
85
|
+
const time = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
86
|
+
if (!Number.isNaN(time)) tracker.time = time
|
|
87
|
+
if (typeof entry.cwd === 'string') tracker.cwd = entry.cwd
|
|
88
|
+
for (const block of blocksOf(entry)) {
|
|
89
|
+
if (block.type === 'tool_use') onToolUse(tracker, block)
|
|
90
|
+
if (block.type === 'tool_result') onResult(tracker, entry, block)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The tasks one Claude Code session log (or subagent log), given as its lines, has in progress at its end: a
|
|
96
|
+
* `TaskCreate` whose `TaskUpdate` set `in_progress` and none completed or deleted, plus the `in_progress` entries of
|
|
97
|
+
* the last `TodoWrite`. A line that is no JSON is skipped. Oldest first.
|
|
98
|
+
*/
|
|
99
|
+
export const claudeSignalsOf = (lines: readonly string[], agent: string): TaskSignal[] => {
|
|
100
|
+
const tracker: Tracker = { pending: new Map(), tasks: new Map(), todos: new Map(), cwd: '', time: 0 }
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const entry = parseLogEntry(line)
|
|
103
|
+
if (entry) visit(tracker, entry)
|
|
104
|
+
}
|
|
105
|
+
const started = [...tracker.tasks.values()].flatMap(({ text, startedAt }) =>
|
|
106
|
+
startedAt === null || !text ? [] : [{ text, startedAt }],
|
|
107
|
+
)
|
|
108
|
+
const todos = [...tracker.todos].map(([text, startedAt]) => ({ text, startedAt }))
|
|
109
|
+
return [...started, ...todos]
|
|
110
|
+
.sort((left, right) => left.startedAt - right.startedAt)
|
|
111
|
+
.map(({ text, startedAt }) => ({ agent, source: 'claude', text, startedAt, cwd: tracker.cwd }))
|
|
112
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mainCheckout } from '../plans/git'
|
|
2
|
+
import { CLI_NAME } from '../version/release'
|
|
3
|
+
import { agentIdentity } from './agent'
|
|
4
|
+
import type { TaskRecord } from './record'
|
|
5
|
+
import { buildTaskId } from './record'
|
|
6
|
+
import { finishTask, openTaskOf, startTask } from './record-store'
|
|
7
|
+
import { cleanTaskText } from './text'
|
|
8
|
+
|
|
9
|
+
export const USAGE = `usage: ${CLI_NAME} task start "<task text>"
|
|
10
|
+
${CLI_NAME} task done ["<task text>"]
|
|
11
|
+
|
|
12
|
+
start report the task you begin; the herd shows it as the one being worked on
|
|
13
|
+
done report it finished; with text, only when it names the open task
|
|
14
|
+
One task per agent: a new start replaces the previous one.`
|
|
15
|
+
|
|
16
|
+
export type TaskCommand =
|
|
17
|
+
| { kind: 'start'; text: string }
|
|
18
|
+
| { kind: 'done'; text: string }
|
|
19
|
+
| { kind: 'help' }
|
|
20
|
+
| { kind: 'usage' }
|
|
21
|
+
|
|
22
|
+
/** The command line as a command: the words after `start` or `done` are the task text, whether quoted or not. */
|
|
23
|
+
export const parseTaskCommand = (args: readonly string[]): TaskCommand => {
|
|
24
|
+
const [name, ...words] = args
|
|
25
|
+
if (name === '--help' || name === '-h') return { kind: 'help' }
|
|
26
|
+
const text = cleanTaskText(words.join(' '))
|
|
27
|
+
if (name === 'start' && text !== '') return { kind: 'start', text }
|
|
28
|
+
if (name === 'done') return { kind: 'done', text }
|
|
29
|
+
return { kind: 'usage' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DoneReport = { exitCode: number; message: string | null }
|
|
33
|
+
|
|
34
|
+
/** What `jh task done` says: nothing without an open task (a second call is no error), else the closed task or why the text did not close it. */
|
|
35
|
+
export const doneReport = (open: TaskRecord | null, closed: TaskRecord | null): DoneReport => {
|
|
36
|
+
if (open === null) return { exitCode: 0, message: null }
|
|
37
|
+
if (closed === null) return { exitCode: 1, message: `the open task is: ${open.text}` }
|
|
38
|
+
return { exitCode: 0, message: `done: ${closed.text}` }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const newId = (): string => buildTaskId(crypto.getRandomValues(new Uint8Array(6)))
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `jh task start "<text>"` reports the task the calling agent begins, `jh task done ["<text>"]` that it is finished.
|
|
45
|
+
* Exit 0; 1 on a wrong command line or when the text of `done` names another task than the open one. `done` without
|
|
46
|
+
* an open task is silent and exits 0.
|
|
47
|
+
*/
|
|
48
|
+
export const run = (args: string[]): number => {
|
|
49
|
+
const command = parseTaskCommand(args)
|
|
50
|
+
if (command.kind === 'help') {
|
|
51
|
+
console.log(USAGE)
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
if (command.kind === 'usage') {
|
|
55
|
+
console.error(USAGE)
|
|
56
|
+
return 1
|
|
57
|
+
}
|
|
58
|
+
const cwd = process.cwd()
|
|
59
|
+
const root = mainCheckout(cwd)
|
|
60
|
+
const agent = agentIdentity(process.env, cwd)
|
|
61
|
+
const now = Date.now()
|
|
62
|
+
if (command.kind === 'start') {
|
|
63
|
+
const record = startTask(root, { agent, text: command.text, cwd, newId }, now)
|
|
64
|
+
console.log(`started: ${record.text}`)
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
const open = openTaskOf(root, agent)
|
|
68
|
+
const { exitCode, message } = doneReport(open, finishTask(root, agent, command.text, now))
|
|
69
|
+
if (message !== null) (exitCode === 0 ? console.log : console.error)(message)
|
|
70
|
+
return exitCode
|
|
71
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:00.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_A","name":"TaskCreate","input":{"subject":"Write the parser","activeForm":"Writing the parser","description":"Parse the input"}}]}}
|
|
2
|
+
{"type":"user","timestamp":"1999-04-03T10:00:01.000Z","cwd":"/work/project","toolUseResult":{"task":{"id":"1","subject":"Write the parser"}},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_A","content":"Task \u00231 created successfully: Write the parser"}]}}
|
|
3
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:02.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_B","name":"TaskCreate","input":{"subject":"Add the tests","activeForm":"Adding the tests"}}]}}
|
|
4
|
+
{"type":"user","timestamp":"1999-04-03T10:00:03.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_B","content":"Task \u00232 created successfully: Add the tests"}]}}
|
|
5
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:04.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_C","name":"TaskUpdate","input":{"taskId":"1","status":"in_progress"}},{"type":"tool_use","id":"toolu_D","name":"TaskUpdate","input":{"taskId":"2","status":"in_progress"}}]}}
|
|
6
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:09.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_E","name":"TaskUpdate","input":{"taskId":"2","status":"completed"}}]}}
|
|
7
|
+
not json
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { MAX_TASK_TEXT } from './text'
|
|
2
|
+
import type { TaskSignal } from './types'
|
|
3
|
+
|
|
4
|
+
/** An open task of a plan: where it stands (`phaseId`, index among the phase's items) and its text. */
|
|
5
|
+
export type OpenPlanTask = { phaseId: string; taskIndex: number; text: string }
|
|
6
|
+
|
|
7
|
+
export type MatchedSignal = { signal: TaskSignal; task: OpenPlanTask | null }
|
|
8
|
+
|
|
9
|
+
/** Shorter than this, a text names nothing: it would match any task. */
|
|
10
|
+
const MIN_TEXT = 4
|
|
11
|
+
/** A whole plan task found inside a longer signal must be this long, or "Tests" claims every signal about tests. */
|
|
12
|
+
const MIN_TASK_INSIDE_SIGNAL = 12
|
|
13
|
+
/** Shorter words (`PR`, `und`, `die`, numbers) carry no meaning for similar wording. */
|
|
14
|
+
const MIN_WORD = 4
|
|
15
|
+
/** Two forms of one word share at least this many letters from the start. */
|
|
16
|
+
const MIN_STEM = 4
|
|
17
|
+
/** And differ in at most this many letters at the end of the shorter one: German endings, `-en`, `-er`, `-e`. */
|
|
18
|
+
const MAX_ENDING = 2
|
|
19
|
+
/** Similar wording needs this many of the signal's words in the task, so two shared words claim nothing. */
|
|
20
|
+
const MIN_SIMILAR_WORDS = 3
|
|
21
|
+
/** And this share of them. */
|
|
22
|
+
const MIN_SIMILAR_SHARE = 0.75
|
|
23
|
+
|
|
24
|
+
const normalised = (text: string): string =>
|
|
25
|
+
text
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
28
|
+
.trim()
|
|
29
|
+
|
|
30
|
+
const headOf = (task: OpenPlanTask): string => task.text.slice(0, MAX_TASK_TEXT)
|
|
31
|
+
|
|
32
|
+
const isPrefix = (signalText: string, task: OpenPlanTask): boolean =>
|
|
33
|
+
signalText.length >= MIN_TEXT && headOf(task).startsWith(signalText)
|
|
34
|
+
|
|
35
|
+
const isContained = (signalText: string, task: OpenPlanTask): boolean => {
|
|
36
|
+
const signal = normalised(signalText)
|
|
37
|
+
const head = normalised(headOf(task))
|
|
38
|
+
if (signal.length < MIN_TEXT) return false
|
|
39
|
+
return head.includes(signal) || (head.length >= MIN_TASK_INSIDE_SIGNAL && signal.includes(head))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const wordsOf = (text: string): string[] =>
|
|
43
|
+
normalised(text)
|
|
44
|
+
.split(' ')
|
|
45
|
+
.filter((word) => word.length >= MIN_WORD)
|
|
46
|
+
|
|
47
|
+
const commonPrefix = (first: string, second: string): number => {
|
|
48
|
+
let length = 0
|
|
49
|
+
while (length < first.length && first[length] === second[length]) length += 1
|
|
50
|
+
return length
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One word, two forms: equal, or the same stem with a short ending of its own (`roter`, `roten`; `kompakter`, `kompaktere`). */
|
|
54
|
+
const sameWord = (first: string, second: string): boolean => {
|
|
55
|
+
const shared = commonPrefix(first, second)
|
|
56
|
+
return first === second || (shared >= MIN_STEM && shared >= Math.min(first.length, second.length) - MAX_ENDING)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How many of the signal's words the task's head holds, and their share of the signal's words. */
|
|
60
|
+
const similarity = (signalWords: readonly string[], task: OpenPlanTask): { matched: number; share: number } => {
|
|
61
|
+
const taskWords = wordsOf(headOf(task))
|
|
62
|
+
const matched = signalWords.filter((word) => taskWords.some((each) => sameWord(word, each))).length
|
|
63
|
+
return { matched, share: signalWords.length === 0 ? 0 : matched / signalWords.length }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The task whose head holds most of the signal's words, each in any inflected form, when at least `MIN_SIMILAR_WORDS`
|
|
68
|
+
* and `MIN_SIMILAR_SHARE` of them; null otherwise. For an agent that names a task in its own words.
|
|
69
|
+
*/
|
|
70
|
+
const similarTask = (tasks: readonly OpenPlanTask[], signalText: string): OpenPlanTask | null => {
|
|
71
|
+
const signalWords = [...new Set(wordsOf(signalText))]
|
|
72
|
+
const scored = tasks
|
|
73
|
+
.map((task) => ({ task, ...similarity(signalWords, task) }))
|
|
74
|
+
.filter(({ matched, share }) => matched >= MIN_SIMILAR_WORDS && share >= MIN_SIMILAR_SHARE)
|
|
75
|
+
return (
|
|
76
|
+
scored.reduce<(typeof scored)[number] | null>(
|
|
77
|
+
(best, each) => (best === null || each.share > best.share ? each : best),
|
|
78
|
+
null,
|
|
79
|
+
)?.task ?? null
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The plan task each signal belongs to, else null (an unplanned task). An exact prefix of a task's text wins over text
|
|
85
|
+
* containment (case, punctuation and spacing ignored, in either direction, on the first 200 characters of the task),
|
|
86
|
+
* and containment over similar wording (`similarTask`). Several signals may match several tasks of several phases,
|
|
87
|
+
* and several signals the same task.
|
|
88
|
+
*/
|
|
89
|
+
export const matchSignals = (tasks: readonly OpenPlanTask[], signals: readonly TaskSignal[]): MatchedSignal[] =>
|
|
90
|
+
signals.map((signal) => ({
|
|
91
|
+
signal,
|
|
92
|
+
task:
|
|
93
|
+
tasks.find((task) => isPrefix(signal.text, task)) ??
|
|
94
|
+
tasks.find((task) => isContained(signal.text, task)) ??
|
|
95
|
+
similarTask(tasks, signal.text),
|
|
96
|
+
}))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { readRecords, writeRecordAtomically } from '../shared/private-records'
|
|
3
|
+
import { isTaskRecord, namesTask, openRecord, type TaskRecord } from './record'
|
|
4
|
+
import { cleanAgentName } from './text'
|
|
5
|
+
|
|
6
|
+
const directoryOf = (root: string): string => join(root, '.jackherd', 'tasks')
|
|
7
|
+
|
|
8
|
+
/** Every well-formed record in the main checkout's `.jackherd/tasks/`, oldest first; nothing when the directory is missing. */
|
|
9
|
+
export const readTaskRecords = (root: string): TaskRecord[] =>
|
|
10
|
+
readRecords(directoryOf(root), isTaskRecord, ({ startedAt }) => startedAt)
|
|
11
|
+
|
|
12
|
+
/** The agent's open record, null when it has none. */
|
|
13
|
+
export const openTaskOf = (root: string, agent: string): TaskRecord | null => {
|
|
14
|
+
const wanted = cleanAgentName(agent)
|
|
15
|
+
return readTaskRecords(root).find((record) => record.agent === wanted && record.closedAt === null) ?? null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Starts a task for `agent`. The agent's one record is reused (its id kept) and reopened with the new task. */
|
|
19
|
+
export const startTask = (
|
|
20
|
+
root: string,
|
|
21
|
+
fields: { agent: string; text: string; cwd: string; newId: () => string },
|
|
22
|
+
now: number,
|
|
23
|
+
): TaskRecord => {
|
|
24
|
+
const previous = readTaskRecords(root).find(({ agent }) => agent === cleanAgentName(fields.agent))
|
|
25
|
+
const record = openRecord({ ...fields, id: previous?.id ?? fields.newId() }, now)
|
|
26
|
+
writeRecordAtomically(directoryOf(root), record)
|
|
27
|
+
return record
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Closes the agent's open task; with `text`, only when it names that task. Returns the closed record, or null when
|
|
32
|
+
* the agent has no open task or the text names another.
|
|
33
|
+
*/
|
|
34
|
+
export const finishTask = (root: string, agent: string, text: string, now: number): TaskRecord | null => {
|
|
35
|
+
const open = openTaskOf(root, agent)
|
|
36
|
+
if (!open || !namesTask(open, text)) return null
|
|
37
|
+
const closed = { ...open, closedAt: now }
|
|
38
|
+
writeRecordAtomically(directoryOf(root), closed)
|
|
39
|
+
return closed
|
|
40
|
+
}
|