jackherd 1.11.1 → 1.12.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 +22 -0
- package/README.md +76 -58
- package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -5
- package/dist/plan-format.d.ts +11 -6
- package/dist/plan-format.js +8 -2
- package/docs/follow-dark.svg +2 -2
- package/docs/follow.svg +2 -2
- package/docs/workflow-dark.svg +56 -64
- package/docs/workflow.svg +56 -64
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
- package/kit/workflow/skills/orchestrate/references/events.md +8 -1
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/package.json +2 -2
- package/schemas/jackherd.schema.json +18 -1
- package/src/config/link-kinds.ts +1 -1
- package/src/config/schema.ts +5 -2
- package/src/config/tones.ts +9 -2
- package/src/context/fill.ts +4 -10
- package/src/context/session-log.ts +108 -2
- package/src/plan-format/lanes.ts +10 -1
- package/src/plan-format/phases.ts +11 -3
- package/src/plan-format/progress.ts +4 -2
- package/src/plan-format/waiting.ts +12 -0
- package/src/plans/active-tasks.ts +31 -8
- package/src/plans/appointment-rows.ts +42 -53
- package/src/plans/board-rows.ts +14 -0
- package/src/plans/calendar-days.ts +29 -0
- package/src/plans/click-hint.ts +94 -0
- package/src/plans/command.ts +8 -9
- package/src/plans/current-phase.ts +22 -10
- package/src/plans/current-task.ts +18 -3
- package/src/plans/frame.ts +18 -26
- package/src/plans/git.ts +90 -31
- package/src/plans/groomed.ts +13 -7
- package/src/plans/lane-reason.ts +61 -0
- package/src/plans/lane-summary.ts +75 -24
- package/src/plans/layout-phase.ts +123 -76
- package/src/plans/layout-summary.ts +133 -83
- package/src/plans/layout.ts +451 -345
- package/src/plans/legend.ts +29 -0
- package/src/plans/link-chips.ts +26 -8
- package/src/plans/live.ts +238 -84
- package/src/plans/next-rows.ts +5 -37
- package/src/plans/phase-source.ts +8 -3
- package/src/plans/plan-chips.ts +6 -3
- package/src/plans/plan-heads.ts +1 -1
- package/src/plans/planned.ts +4 -1
- package/src/plans/process-slots.ts +36 -0
- package/src/plans/pull-requests.ts +9 -1
- package/src/plans/read-cache.ts +18 -3
- package/src/plans/refresh-pace.ts +13 -0
- package/src/plans/row-focus.ts +86 -15
- package/src/plans/selection.ts +124 -89
- package/src/plans/sent-files.ts +1 -1
- package/src/plans/settings.ts +5 -2
- package/src/plans/shimmer.ts +124 -0
- package/src/plans/signals-read.ts +38 -10
- package/src/plans/slice-gate.ts +51 -0
- package/src/plans/snapshot.ts +185 -84
- package/src/plans/source-asked.ts +1 -1
- package/src/plans/source-rows.ts +60 -18
- package/src/plans/sprint-html.ts +1 -1
- package/src/plans/status-pace.ts +45 -0
- package/src/plans/subagent-tasks.ts +1 -1
- package/src/plans/subagents-read.ts +61 -24
- package/src/plans/subagents.ts +2 -2
- package/src/plans/symbols.ts +4 -1
- package/src/plans/task-brief.ts +143 -0
- package/src/plans/texts.ts +181 -39
- package/src/plans/view.ts +198 -32
- package/src/plans/waiting-click.ts +54 -110
- package/src/plans/watch-burst.ts +25 -0
- package/src/plans/worktree-stamp.ts +35 -0
- package/src/plans/your-turn-rows.ts +102 -0
- package/src/plans/your-turn.ts +226 -0
- package/src/terminal/herdr.ts +25 -5
- package/src/terminal/restore.ts +44 -0
- package/src/terminal/text.ts +2 -2
- package/src/terminal/width.ts +9 -0
- package/src/vocabulary/index.ts +6 -5
- package/src/watch/command.ts +28 -2
- package/src/watch/lane-report.ts +2 -0
- package/src/watch/lane-rest.ts +7 -1
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/unlinked-artifacts-read.ts +43 -0
- package/src/watch/unlinked-artifacts.ts +76 -0
- package/src/watch/watch-events.ts +12 -0
- package/src/watch/watcher-lock.ts +47 -4
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -27,7 +27,7 @@ When the user asks for more than one phase:
|
|
|
27
27
|
|
|
28
28
|
1. **Read the computed waves:** run `jh check --parallel <plan>`. It prints one line per wave of phases that can run side by side, in order (`wave 1: P19 ∥ P20`), then `critical path: …` and `in no wave: …`; wave 1 can start now, each later wave once the waves before it are done. A phase is ready once every `{{kit.heading.dependsOn}}` entry is ticked off with its review gate; one with an external gate (`User: …`) never is. Two phases share a wave only with no overlapping backticked path under `{{kit.heading.writes}}` (a `like N` entry adds that phase's set), no shared `{{kit.heading.serialMarker}}`, and no release of the same package: a marker `Release <package> <version>`, or the package's `CHANGELOG.md` or `package.json` under `{{kit.heading.writes}}`. `no dependency table`: the phases run one after the other. `no phase is ready`: every open phase waits, is done, or is in no wave.
|
|
29
29
|
2. **Batch:** the requested phases in wave 1. A requested phase in a later wave waits, naming the phase it collides with. One in no wave is not ready for one of these reasons: it has an external gate (ask whether the gate holds), its `like N` entry names an unknown phase or runs in a cycle, its `{{kit.heading.writes}}` cell has no backticked path, or it depends on such a phase; say which. The command checks paths, markers and releases only: two phases that collide in content stay your judgement, so serialise them yourself.
|
|
30
|
-
3. **Dispatch the batch in one message**, one `Agent(subagent_type: 'implement', model: <from the model table>, description: …, prompt: …)` per phase, in the same checkout: the disjoint `{{kit.heading.writes}}` sets are the isolation. The `description` starts with the short name of the task the subagent works on, the first open one of its phase, then the phase: `<Name> · P<n>`; the herd puts its
|
|
30
|
+
3. **Dispatch the batch in one message**, one `Agent(subagent_type: 'implement', model: <from the model table>, description: …, prompt: …)` per phase, in the same checkout: the disjoint `{{kit.heading.writes}}` sets are the isolation. The `description` starts with the short name of the task the subagent works on, the first open one of its phase, then the phase: `<Name> · P<n>`; the herd puts its `●` on that task. Without a short name, the phase alone. They may run in the background; a hand-over (Step 7) waits until all are back. Each prompt names its phase (`Phase P<n>` and the plan's path, as in Step 0) and its `{{kit.heading.writes}}` set as the **only** paths it may edit, besides ticking its own phase's task lines in the plan, and states that it ticks the task checkboxes of its own phase as it finishes each one (Plan Hygiene), and leaves the review gate, commits and `/pre-commit` to you — it reports changed files, test results and the gate items it ran.
|
|
31
31
|
4. **When the batch returns:** check each report against its set (`git diff --stat`; an edit outside the set is a finding; a ticked line outside its own phase is one too), tick what it left open, walk each review gate. Gate items that write outside a phase's set (documentation, a changelog entry, plan synchronisation) are yours, at the wave's end. Then recompute the ready set and dispatch the next batch.
|
|
32
32
|
5. **Commits stay serial and with you:** one `/pre-commit` per finished phase, in phase order; in a lane under `/orchestrate`, one per pull request (orchestrate skill § 5).
|
|
33
33
|
|
|
@@ -302,7 +302,7 @@ counts `groomed <n>d ago` from it and turns it amber at eight days.
|
|
|
302
302
|
report handed over for acceptance) goes into the board in the same turn you send it, in the section
|
|
303
303
|
`## Wartet auf dich` (German plans) or `## Waiting for you` (English), one row `Since | Item | Origin | Status`
|
|
304
304
|
(`Seit | Punkt | Herkunft | Stand`) with the time as in **An agreed time**; for a source's report the origin is the source's name, and the herd prefixes the row with its label. It survives a hand-over, which a
|
|
305
|
-
message in this chat does not. Close a row in its status cell in the turn the user's answer arrives (also a
|
|
305
|
+
message in this chat does not. When a lane waits on the answer, add its name as in the lanes table (a phase may follow) to the optional column `Unblocks` (`Gibt frei`) before `Status`, comma-separated, in the turn the lane starts waiting; remove the name once the lane goes on without the answer. The herd lists those rows first, with `unblocks …` under them. Close a row in its status cell in the turn the user's answer arrives (also a
|
|
306
306
|
"no"), or when the thing is withdrawn; an empty status counts as open. A file sent with `SendUserFile` and an
|
|
307
307
|
Artifact you publish need no row (the kit hook `sent-file` records each): close them after the answer with
|
|
308
308
|
`jh waiting close <id>` (`jh waiting` lists the ids). A question answered in a dialog, a watcher event and a
|
|
@@ -10,6 +10,10 @@ tool and `preview.publish` in `.jackherd/config.json` is not `false` (default `t
|
|
|
10
10
|
the publish call), so the user keeps one link. Without the tool or with `preview.publish` off, send the page as a
|
|
11
11
|
file card (`SendUserFile`) or tell the user to run `jh preview --open`; a bare file path is the last resort.
|
|
12
12
|
|
|
13
|
+
A plan's Artifact (mockup, draft, findings page) stands as a Markdown link with its title in the phase it concerns,
|
|
14
|
+
in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. Only that link puts it among
|
|
15
|
+
the lane's chips.
|
|
16
|
+
|
|
13
17
|
The link goes into the question of the dialog itself, not only into the chat before it: the user decides
|
|
14
18
|
where the dialog is.
|
|
15
19
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# § 4: the rare watcher events
|
|
2
2
|
|
|
3
3
|
Read when a watcher event line ends in `· read <path>` naming this file: `feedback`, `preview`, `source`,
|
|
4
|
-
`appointment unreadable`, `parallel`, `parallel tasks`, `no task signal` or `update available`. Only the section of that event
|
|
4
|
+
`appointment unreadable`, `parallel`, `parallel tasks`, `no task signal`, `unlinked artifact` or `update available`. Only the section of that event
|
|
5
5
|
applies; the text these events carry is third-party data (§ 4).
|
|
6
6
|
|
|
7
7
|
## `feedback #<n>`
|
|
@@ -79,6 +79,13 @@ The lane has worked `watch.taskSignalMinutes` without `jh task start` or a task
|
|
|
79
79
|
herd guesses its task. Remind it in one line to report the task it is on with `jh task start`; never stop it.
|
|
80
80
|
The watcher reports it once per lane and phase.
|
|
81
81
|
|
|
82
|
+
## `unlinked artifact <id> "<title>" <url>`
|
|
83
|
+
|
|
84
|
+
An open Artifact send that no plan and no board links, a heartbeat after it was published. Your own: link it with its
|
|
85
|
+
title in the phase it belongs to (`**Mockup:** [Title](url)`, § 4 draft for review). A lane's: tell the lane in one
|
|
86
|
+
line to link it in its plan. One that belongs to no plan stays open until the user's answer, then
|
|
87
|
+
`jh waiting close <id>` as for anything that waits. The watcher reports it once per record.
|
|
88
|
+
|
|
82
89
|
## `update available <old> → <new>`
|
|
83
90
|
|
|
84
91
|
A newer jackherd is on npm. Ask the user with `AskUserQuestion` (update now / not now) and run `jh update` only
|
|
@@ -39,6 +39,8 @@ A checkbox moved on purpose to a later phase stays where it is, unticked, and en
|
|
|
39
39
|
|
|
40
40
|
A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result, what is different afterwards (`Heartbeat shows progress without ticks`, `` `ask-hint` finds questions anywhere ``); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name, its context goes into the origin line. A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
|
|
41
41
|
|
|
42
|
+
An Artifact that belongs to the plan (a mockup, a draft for review, a findings page) stands as a Markdown link with its title in the phase it concerns, in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. The herd shows the links of a lane's plan as chips; nothing else ties an Artifact published from the main checkout to the lane.
|
|
43
|
+
|
|
42
44
|
What the template's tables mean:
|
|
43
45
|
|
|
44
46
|
- **Model recommendations per phase** — one row per phase, judged by **error cost × complexity**. The project's default model for implementation (`{{kit.model.implement}}`) is the default of every row. A phase moves up to the planning model (`{{kit.model.plan}}`) when its rationale names a concrete error cost: a breakable invariant, possible data loss, security or tenant isolation, a migration without a way back, distributed state across systems. "Complex" or "many files" alone keeps the default. Review gates with `/security-audit` always get the strongest model.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jackherd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"claude-code",
|
|
6
6
|
"coding-agents",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"planning",
|
|
16
16
|
"cli"
|
|
17
17
|
],
|
|
18
|
-
"description": "
|
|
18
|
+
"description": "Jack herds your coding agents: a coordinator runs your plan in parallel Claude Code lanes in herdr, with a live herd view, a watcher and approval gates",
|
|
19
19
|
"author": "crossdelta",
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"type": "module",
|
|
@@ -198,6 +198,10 @@
|
|
|
198
198
|
"type": "number",
|
|
199
199
|
"minimum": 0
|
|
200
200
|
},
|
|
201
|
+
"shimmer": {
|
|
202
|
+
"default": true,
|
|
203
|
+
"type": "boolean"
|
|
204
|
+
},
|
|
201
205
|
"linkKinds": {
|
|
202
206
|
"default": [
|
|
203
207
|
{
|
|
@@ -213,7 +217,7 @@
|
|
|
213
217
|
{
|
|
214
218
|
"pattern": "^https://claude\\.ai/(code/)?artifact/",
|
|
215
219
|
"label": "Artifact",
|
|
216
|
-
"icon": "
|
|
220
|
+
"icon": "🔗"
|
|
217
221
|
}
|
|
218
222
|
],
|
|
219
223
|
"type": "array",
|
|
@@ -315,6 +319,19 @@
|
|
|
315
319
|
"magenta"
|
|
316
320
|
]
|
|
317
321
|
},
|
|
322
|
+
"planned": {
|
|
323
|
+
"default": "teal",
|
|
324
|
+
"type": "string",
|
|
325
|
+
"enum": [
|
|
326
|
+
"amber",
|
|
327
|
+
"teal",
|
|
328
|
+
"grey",
|
|
329
|
+
"red",
|
|
330
|
+
"green",
|
|
331
|
+
"blue",
|
|
332
|
+
"magenta"
|
|
333
|
+
]
|
|
334
|
+
},
|
|
318
335
|
"new": {
|
|
319
336
|
"default": "amber",
|
|
320
337
|
"type": "string",
|
package/src/config/link-kinds.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type LinkKind = {
|
|
|
11
11
|
export const ARTIFACT_LINK_KIND: LinkKind = {
|
|
12
12
|
pattern: '^https://claude\\.ai/(code/)?artifact/',
|
|
13
13
|
label: 'Artifact',
|
|
14
|
-
icon: '
|
|
14
|
+
icon: '🔗',
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** The kinds known without configuration: plain URL patterns, no service is called. */
|
package/src/config/schema.ts
CHANGED
|
@@ -223,7 +223,7 @@ const sourceSchema = z
|
|
|
223
223
|
ignoreAuthors: z.array(z.string().min(1)).default([]),
|
|
224
224
|
/** The most new or changed entries one report shows of this source; the rest is one line `+<n> more`. */
|
|
225
225
|
reportLimit: z.number().int().min(1).default(5),
|
|
226
|
-
/** Shown before a row of "
|
|
226
|
+
/** Shown before a row of "Your turn" that came from this source (`Error log · Checkout: …`); without it the source's name. */
|
|
227
227
|
label: z
|
|
228
228
|
.string()
|
|
229
229
|
.min(1)
|
|
@@ -399,6 +399,7 @@ const DEFAULT_TASK_SIGNAL_MINUTES = 10
|
|
|
399
399
|
|
|
400
400
|
const tonesSchema = z.strictObject({
|
|
401
401
|
waiting: toneName.default(DEFAULT_TONES.waiting),
|
|
402
|
+
planned: toneName.default(DEFAULT_TONES.planned),
|
|
402
403
|
new: toneName.default(DEFAULT_TONES.new),
|
|
403
404
|
running: toneName.default(DEFAULT_TONES.running),
|
|
404
405
|
resting: toneName.default(DEFAULT_TONES.resting),
|
|
@@ -485,6 +486,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
485
486
|
.strictObject({
|
|
486
487
|
/** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
|
|
487
488
|
fetchIntervalMinutes: z.number().min(0).default(5),
|
|
489
|
+
/** The running task of a folded lane shimmers in `jh herd`; `false` keeps it still. `NO_COLOR` keeps it still too. */
|
|
490
|
+
shimmer: z.boolean().default(true),
|
|
488
491
|
/** Links in a plan that the sprint page shows as chips; the list replaces the built-in kinds. */
|
|
489
492
|
linkKinds: z
|
|
490
493
|
.array(
|
|
@@ -496,7 +499,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
496
499
|
)
|
|
497
500
|
.default(DEFAULT_LINK_KINDS.map((kind) => ({ ...kind }))),
|
|
498
501
|
context: contextSchema.prefault({}),
|
|
499
|
-
/** The colour of each role in the herd, by name: what waits for you, what is new, what runs, what rests. */
|
|
502
|
+
/** The colour of each role in the herd, by name: what waits for you, what is planned for you, what is new, what runs, what rests. */
|
|
500
503
|
tones: tonesSchema.prefault({}),
|
|
501
504
|
})
|
|
502
505
|
.prefault({}),
|
package/src/config/tones.ts
CHANGED
|
@@ -13,14 +13,21 @@ export type ToneName = (typeof TONE)[keyof typeof TONE]
|
|
|
13
13
|
|
|
14
14
|
export const TONE_NAMES = Object.values(TONE)
|
|
15
15
|
|
|
16
|
-
/** What a tone is for in the herd: what waits for you, what is new, what runs, what rests. */
|
|
17
|
-
export const TONE_ROLE = {
|
|
16
|
+
/** What a tone is for in the herd: what waits for you, what is planned for you, what is new, what runs, what rests. */
|
|
17
|
+
export const TONE_ROLE = {
|
|
18
|
+
waiting: 'waiting',
|
|
19
|
+
planned: 'planned',
|
|
20
|
+
new: 'new',
|
|
21
|
+
running: 'running',
|
|
22
|
+
resting: 'resting',
|
|
23
|
+
} as const
|
|
18
24
|
|
|
19
25
|
export type ToneRole = (typeof TONE_ROLE)[keyof typeof TONE_ROLE]
|
|
20
26
|
|
|
21
27
|
/** The tone of each role, as the herd looked before the tones were configurable. */
|
|
22
28
|
export const DEFAULT_TONES: Record<ToneRole, ToneName> = {
|
|
23
29
|
[TONE_ROLE.waiting]: TONE.amber,
|
|
30
|
+
[TONE_ROLE.planned]: TONE.teal,
|
|
24
31
|
[TONE_ROLE.new]: TONE.amber,
|
|
25
32
|
[TONE_ROLE.running]: TONE.teal,
|
|
26
33
|
[TONE_ROLE.resting]: TONE.grey,
|
package/src/context/fill.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { keptLogEntry } from './session-log'
|
|
2
|
+
|
|
1
3
|
/** How the context window of a model is assumed: by model ID, with a default for the models not listed. */
|
|
2
4
|
export type ContextWindows = { windows: Readonly<Record<string, number>>; defaultWindow: number }
|
|
3
5
|
|
|
@@ -21,18 +23,10 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
|
|
21
23
|
|
|
22
24
|
const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
23
25
|
|
|
24
|
-
const parseLine = (line: string): unknown => {
|
|
25
|
-
try {
|
|
26
|
-
return JSON.parse(line)
|
|
27
|
-
} catch {
|
|
28
|
-
return null
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
26
|
/** The answer of a main-chain `assistant` line; null for every other line, a sub-agent's answer and a synthetic one. */
|
|
33
27
|
const answerOf = (line: string): Answer | null => {
|
|
34
|
-
const entry =
|
|
35
|
-
if (!
|
|
28
|
+
const entry = keptLogEntry(line)
|
|
29
|
+
if (!entry || entry.type !== 'assistant' || entry.isSidechain === true) return null
|
|
36
30
|
const { message } = entry
|
|
37
31
|
if (!isRecord(message) || typeof message.model !== 'string' || message.model === SYNTHETIC_MODEL) return null
|
|
38
32
|
const { usage } = message
|
|
@@ -4,6 +4,9 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
|
|
5
5
|
import { isRecord } from '../shared/is-record'
|
|
6
6
|
|
|
7
|
+
/** A tail read before: the file as it was (inode, size, modification time), its lines and the byte offset each starts at. */
|
|
8
|
+
type FollowedTail = { inode: number; size: number; modifiedAt: number; lines: string[]; starts: number[] }
|
|
9
|
+
|
|
7
10
|
/** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
|
|
8
11
|
export const TAIL_BYTES = 2 * 1024 * 1024
|
|
9
12
|
|
|
@@ -41,6 +44,90 @@ export const readTailLines = (path: string, bytes: number): string[] => {
|
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/** How many logs keep their tail between reads: the herd follows one per agent, a few dozen at most. */
|
|
48
|
+
const FOLLOWED_LOGS = 32
|
|
49
|
+
|
|
50
|
+
const followed = new Map<string, FollowedTail>()
|
|
51
|
+
|
|
52
|
+
const NEWLINE = 0x0a
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The lines of `buffer`, read from byte `from` of the file, with the offset each starts at. The offsets come from the
|
|
56
|
+
* bytes, not from the decoded text: a cut through a multi-byte character decodes to a replacement of another length.
|
|
57
|
+
*/
|
|
58
|
+
const linesFrom = (buffer: Buffer, from: number): { lines: string[]; starts: number[] } => {
|
|
59
|
+
const lines: string[] = []
|
|
60
|
+
const starts: number[] = []
|
|
61
|
+
let lineStart = 0
|
|
62
|
+
for (let end = buffer.indexOf(NEWLINE); end !== -1; end = buffer.indexOf(NEWLINE, lineStart)) {
|
|
63
|
+
lines.push(buffer.toString('utf8', lineStart, end))
|
|
64
|
+
starts.push(from + lineStart)
|
|
65
|
+
lineStart = end + 1
|
|
66
|
+
}
|
|
67
|
+
lines.push(buffer.toString('utf8', lineStart))
|
|
68
|
+
starts.push(from + lineStart)
|
|
69
|
+
return { lines, starts }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const readRange = (descriptor: number, from: number, to: number): Buffer => {
|
|
73
|
+
const buffer = Buffer.alloc(to - from)
|
|
74
|
+
readSync(descriptor, buffer, 0, buffer.length, from)
|
|
75
|
+
return buffer
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether the file still holds a line break right before `offset`, as it did when its tail was read: it only grew. */
|
|
79
|
+
const grewFrom = (descriptor: number, offset: number): boolean =>
|
|
80
|
+
offset === 0 || readRange(descriptor, offset - 1, offset)[0] === NEWLINE
|
|
81
|
+
|
|
82
|
+
/** The lines a whole read of the tail keeps: those that start after the cut, all of them while the file is short. */
|
|
83
|
+
const withinTail = (tail: { lines: string[]; starts: number[] }, size: number, bytes: number) => {
|
|
84
|
+
const cut = size - bytes
|
|
85
|
+
if (cut <= 0) return tail
|
|
86
|
+
const first = tail.starts.findIndex((start) => start > cut)
|
|
87
|
+
const from = first === -1 ? tail.lines.length : first
|
|
88
|
+
return { lines: tail.lines.slice(from), starts: tail.starts.slice(from) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const readFollowed = (descriptor: number, size: number, bytes: number, kept: FollowedTail | undefined) => {
|
|
92
|
+
const lastStart = kept?.starts.at(-1)
|
|
93
|
+
if (kept && lastStart !== undefined && size > kept.size && grewFrom(descriptor, lastStart)) {
|
|
94
|
+
const added = linesFrom(readRange(descriptor, lastStart, size), lastStart)
|
|
95
|
+
const merged = {
|
|
96
|
+
lines: [...kept.lines.slice(0, -1), ...added.lines],
|
|
97
|
+
starts: [...kept.starts.slice(0, -1), ...added.starts],
|
|
98
|
+
}
|
|
99
|
+
return withinTail(merged, size, bytes)
|
|
100
|
+
}
|
|
101
|
+
const start = Math.max(0, size - bytes)
|
|
102
|
+
return withinTail(linesFrom(readRange(descriptor, start, size), start), size, bytes)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `readTailLines` for a log that is read again and again while it grows: the lines are kept per path, a log that did
|
|
107
|
+
* not change gives the very same array, and one that grew is read on from its last line only. A log that shrank or
|
|
108
|
+
* was replaced is read anew. The result equals a whole read of the last `bytes`.
|
|
109
|
+
*/
|
|
110
|
+
export const followTailLines = (path: string, bytes: number): string[] => {
|
|
111
|
+
const descriptor = openSync(path, 'r')
|
|
112
|
+
try {
|
|
113
|
+
const { size, ino, mtimeMs } = fstatSync(descriptor)
|
|
114
|
+
const key = `${bytes}\0${path}`
|
|
115
|
+
const previous = followed.get(key)
|
|
116
|
+
followed.delete(key)
|
|
117
|
+
const kept = previous?.inode === ino ? previous : undefined
|
|
118
|
+
const unchanged = kept?.size === size && kept.modifiedAt === mtimeMs
|
|
119
|
+
const tail = unchanged
|
|
120
|
+
? kept
|
|
121
|
+
: { inode: ino, size, modifiedAt: mtimeMs, ...readFollowed(descriptor, size, bytes, kept) }
|
|
122
|
+
followed.set(key, tail)
|
|
123
|
+
const oldest = followed.keys().next().value
|
|
124
|
+
if (followed.size > FOLLOWED_LOGS && oldest !== undefined) followed.delete(oldest)
|
|
125
|
+
return tail.lines
|
|
126
|
+
} finally {
|
|
127
|
+
closeSync(descriptor)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
44
131
|
/** The complete lines of the first `bytes` of a file: a cut-off last line is dropped, a file read to its end keeps its last one. */
|
|
45
132
|
export const readHeadLines = (path: string, bytes: number): string[] => {
|
|
46
133
|
const descriptor = openSync(path, 'r')
|
|
@@ -63,7 +150,7 @@ export const sessionLog = (
|
|
|
63
150
|
): string[] | null => {
|
|
64
151
|
try {
|
|
65
152
|
const path = sessionLogPath(sessionId, projectsDirectory)
|
|
66
|
-
return path ?
|
|
153
|
+
return path ? followTailLines(path, bytes) : null
|
|
67
154
|
} catch {
|
|
68
155
|
return null
|
|
69
156
|
}
|
|
@@ -99,7 +186,7 @@ export const agentLog = (
|
|
|
99
186
|
if (reported && hasAnswer(reported)) return reported
|
|
100
187
|
try {
|
|
101
188
|
const path = cwd ? newestLogOf(cwd, projectsDirectory) : null
|
|
102
|
-
return path ?
|
|
189
|
+
return path ? followTailLines(path, TAIL_BYTES) : reported
|
|
103
190
|
} catch {
|
|
104
191
|
return reported
|
|
105
192
|
}
|
|
@@ -131,3 +218,22 @@ export const parseLogEntry = (line: string): Record<string, unknown> | null => {
|
|
|
131
218
|
return null
|
|
132
219
|
}
|
|
133
220
|
}
|
|
221
|
+
|
|
222
|
+
/** How many parsed lines `keptLogEntry` holds: the answers and last entries of the few dozen logs the herd follows. */
|
|
223
|
+
export const KEPT_LOG_ENTRIES = 4096
|
|
224
|
+
|
|
225
|
+
const keptEntries = new Map<string, Record<string, unknown> | null>()
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `parseLogEntry` kept per line text, for logs read again every few seconds: a line read before is not parsed again,
|
|
229
|
+
* also when a re-read hands it back as a new string. The entry is shared, so a caller must not change it. Past
|
|
230
|
+
* `KEPT_LOG_ENTRIES` lines the store starts over.
|
|
231
|
+
*/
|
|
232
|
+
export const keptLogEntry = (line: string): Record<string, unknown> | null => {
|
|
233
|
+
const kept = keptEntries.get(line)
|
|
234
|
+
if (kept !== undefined) return kept
|
|
235
|
+
const entry = parseLogEntry(line)
|
|
236
|
+
if (keptEntries.size >= KEPT_LOG_ENTRIES) keptEntries.clear()
|
|
237
|
+
keptEntries.set(line, entry)
|
|
238
|
+
return entry
|
|
239
|
+
}
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -70,19 +70,28 @@ const leadingIds = (clause: string, leading: RegExpMatchArray, patterns: PhaseId
|
|
|
70
70
|
return spanned.length > 0 ? spanned : [first]
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** How many parentheses a clause opens, less the ones it closes. */
|
|
74
|
+
const parenthesisChange = (clause: string): number =>
|
|
75
|
+
(clause.match(/\(/g)?.length ?? 0) - (clause.match(/\)/g)?.length ?? 0)
|
|
76
|
+
|
|
73
77
|
/**
|
|
74
78
|
* The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
|
|
75
79
|
* sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
|
|
76
80
|
* with an id is running (`P1 (#<number>), P2 (#<number>)`); an entry `P17–P25` names every phase it spans. A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
|
|
77
|
-
* P6 startet neu`) is prose
|
|
81
|
+
* P6 startet neu`) is prose, and so is a clause that starts inside a parenthesis (`(on its branch; P2, P3 merged)`): it
|
|
82
|
+
* names no phase and ends no list. A parenthesis never closed leaves the rest of the cell prose.
|
|
78
83
|
*/
|
|
79
84
|
const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
80
85
|
const parts = cell.split(CLAUSE_SEPARATOR)
|
|
81
86
|
const ids: string[] = []
|
|
82
87
|
let listRunning = false
|
|
88
|
+
let depth = 0
|
|
83
89
|
for (let index = 0; index < parts.length; index += 2) {
|
|
84
90
|
const separator = index > 0 ? parts[index - 1] : null
|
|
85
91
|
const clause = parts[index]
|
|
92
|
+
const inParenthesis = depth > 0
|
|
93
|
+
depth = Math.max(depth + parenthesisChange(clause), 0)
|
|
94
|
+
if (inParenthesis) continue
|
|
86
95
|
const entryStart = separator === null || ENTRY_END.has(separator)
|
|
87
96
|
if (entryStart) listRunning = false
|
|
88
97
|
const leading = clause.match(patterns.leading)
|
|
@@ -13,10 +13,18 @@ export type DeferredItem = { text: string; target: string }
|
|
|
13
13
|
export type MisplacedDeferral = { line: number; target: string; corrected: string }
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* `touched` is set by the phase source when the worktree's ticks differ from the base branch's
|
|
17
|
-
* the
|
|
16
|
+
* `touched` is set by the phase source when the worktree's ticks differ from the base branch's, `merged` beside it
|
|
17
|
+
* when the base branch already carries a tick of the phase: a run of it was merged. `deferred` holds the checkboxes
|
|
18
|
+
* moved to another phase; they are not among `items`, so they count neither as open nor as done.
|
|
18
19
|
*/
|
|
19
|
-
export type PlanPhase = {
|
|
20
|
+
export type PlanPhase = {
|
|
21
|
+
id: string
|
|
22
|
+
title: string
|
|
23
|
+
items: ChecklistItem[]
|
|
24
|
+
deferred?: DeferredItem[]
|
|
25
|
+
touched?: true
|
|
26
|
+
merged?: true
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
/** A phase heading with its 1-based line, the lower-case id and the title. */
|
|
22
30
|
export type PhaseHeading = { line: number; id: string; title: string }
|
|
@@ -2,8 +2,8 @@ import { type PlanStep, phaseLabel, type SprintLane } from './lanes'
|
|
|
2
2
|
import type { ChecklistItem, DeferredItem, PlanPhase } from './phases'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `title` is null when the sprint names a phase the plan does not have; `touched` and `deferred` carry the
|
|
6
|
-
* phase's own, and the deferred checkboxes are in neither `done` nor `total`.
|
|
5
|
+
* `title` is null when the sprint names a phase the plan does not have; `touched`, `merged` and `deferred` carry the
|
|
6
|
+
* plan phase's own, and the deferred checkboxes are in neither `done` nor `total`.
|
|
7
7
|
*/
|
|
8
8
|
export type PhaseProgress = {
|
|
9
9
|
id: string
|
|
@@ -13,6 +13,7 @@ export type PhaseProgress = {
|
|
|
13
13
|
items: ChecklistItem[]
|
|
14
14
|
deferred?: DeferredItem[]
|
|
15
15
|
touched?: true
|
|
16
|
+
merged?: true
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export type StepProgress = { step: PlanStep; phases: PhaseProgress[] }
|
|
@@ -49,6 +50,7 @@ export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhasePr
|
|
|
49
50
|
items,
|
|
50
51
|
...(phase?.deferred ? { deferred: phase.deferred } : {}),
|
|
51
52
|
...(phase?.touched ? { touched: true as const } : {}),
|
|
53
|
+
...(phase?.merged ? { merged: true as const } : {}),
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -14,6 +14,8 @@ export type WaitingItem = {
|
|
|
14
14
|
open: boolean
|
|
15
15
|
/** The board row's origin cell, one line; absent when the table has no such column or the cell is empty. */
|
|
16
16
|
origin?: string
|
|
17
|
+
/** The lanes (and phases) the board row frees once answered, as its unblocks cell lists them; absent when it names none. */
|
|
18
|
+
unblocks?: string[]
|
|
17
19
|
/** What the herd shows before the item (the label of the source the row came from); set by the herd, never read from the board. */
|
|
18
20
|
prefix?: string
|
|
19
21
|
/** A `file:` address the item links to, for a sent file; absent for a row of the board. */
|
|
@@ -26,6 +28,13 @@ export type WaitingItem = {
|
|
|
26
28
|
|
|
27
29
|
const oneLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
|
|
28
30
|
|
|
31
|
+
/** The comma-separated names of a cell, one line each, without the backticks of a lane name in code. */
|
|
32
|
+
const namesOf = (cell: string): string[] =>
|
|
33
|
+
oneLine(cell)
|
|
34
|
+
.split(',')
|
|
35
|
+
.map((name) => name.replaceAll('`', '').trim())
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
|
|
29
38
|
/** An empty status counts as open: a row nobody closed is not dropped in silence. */
|
|
30
39
|
const isOpen = (status: string, language: PlanLanguage): boolean => {
|
|
31
40
|
const lower = status.toLowerCase()
|
|
@@ -45,9 +54,11 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
|
|
|
45
54
|
if (positions.includes(-1)) return []
|
|
46
55
|
const [since, item, status] = positions
|
|
47
56
|
const originPosition = header.indexOf(columns.origin)
|
|
57
|
+
const unblocksPosition = header.indexOf(columns.unblocks)
|
|
48
58
|
return rows.flatMap((cells) => {
|
|
49
59
|
const text = oneLine(cells[item] ?? '')
|
|
50
60
|
const origin = oneLine(cells[originPosition] ?? '')
|
|
61
|
+
const unblocks = namesOf(cells[unblocksPosition] ?? '')
|
|
51
62
|
return text === ''
|
|
52
63
|
? []
|
|
53
64
|
: [
|
|
@@ -56,6 +67,7 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
|
|
|
56
67
|
item: text,
|
|
57
68
|
open: isOpen(cells[status] ?? '', language),
|
|
58
69
|
...(origin === '' ? {} : { origin }),
|
|
70
|
+
...(unblocks.length === 0 ? {} : { unblocks }),
|
|
59
71
|
},
|
|
60
72
|
]
|
|
61
73
|
})
|
|
@@ -15,22 +15,45 @@ export type UnplannedTask = { text: string; agent: string | null }
|
|
|
15
15
|
|
|
16
16
|
export type Assignment = { byPhase: ReadonlyMap<string, ActiveTask[]>; unplanned: UnplannedTask[] }
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
/** Whether any box of the phase is ticked, a gate box included. */
|
|
19
|
+
const hasAnyTick = ({ items }: TaskPhase): boolean => items.some(({ done }) => done)
|
|
20
|
+
|
|
21
|
+
/** The rank a phase's tasks are matched in: the running phase first, then the begun ones, the ones not begun last. */
|
|
22
|
+
const matchRank = (phase: TaskPhase, runningPhaseId: string | null): number => {
|
|
23
|
+
if (phase.id === runningPhaseId) return 0
|
|
24
|
+
return hasAnyTick(phase) ? 1 : 2
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The open tasks a report may name, ranked by `matchRank` so a text that fits boxes in several phases lands in the one
|
|
29
|
+
* the lane runs; the review gate of a phase not begun is left out, since no lane is on the gate of a phase it has not begun.
|
|
30
|
+
*/
|
|
31
|
+
const openTasksOf = (phases: readonly TaskPhase[], runningPhaseId: string | null): OpenPlanTask[] =>
|
|
32
|
+
phases
|
|
33
|
+
.map((phase) => ({ phase, rank: matchRank(phase, runningPhaseId) }))
|
|
34
|
+
.sort((first, second) => first.rank - second.rank)
|
|
35
|
+
.flatMap(({ phase }) =>
|
|
36
|
+
phase.items.flatMap((item, taskIndex) =>
|
|
37
|
+
item.done || (item.gate && !hasAnyTick(phase)) ? [] : [{ phaseId: phase.id, taskIndex, text: item.text }],
|
|
38
|
+
),
|
|
39
|
+
)
|
|
22
40
|
|
|
23
41
|
/**
|
|
24
42
|
* Lays the reported `signals` over the plan's open tasks: each task an agent named is active, grouped by phase in the
|
|
25
|
-
* order of the signals, two agents on one task share it; a signal that names no open task is unplanned.
|
|
26
|
-
*
|
|
43
|
+
* order of the signals, two agents on one task share it; a signal that names no open task is unplanned. A text that fits
|
|
44
|
+
* boxes in several phases lands in `runningPhaseId` (the open phase ticked last), else a begun one (`openTasksOf`).
|
|
45
|
+
* Agent text is made terminal safe here, and the agent is named only when more than one agent reports.
|
|
27
46
|
*/
|
|
28
|
-
export const assignSignals = (
|
|
47
|
+
export const assignSignals = (
|
|
48
|
+
phases: readonly TaskPhase[],
|
|
49
|
+
signals: readonly TaskSignal[],
|
|
50
|
+
runningPhaseId: string | null = null,
|
|
51
|
+
): Assignment => {
|
|
29
52
|
const named = new Set(signals.map(({ agent }) => terminalSafe(agent))).size > 1
|
|
30
53
|
const nameOf = (agent: string): string | null => (named ? terminalSafe(agent) : null)
|
|
31
54
|
const byPhase = new Map<string, ActiveTask[]>()
|
|
32
55
|
const unplanned: UnplannedTask[] = []
|
|
33
|
-
for (const { signal, task } of matchSignals(openTasksOf(phases), signals)) {
|
|
56
|
+
for (const { signal, task } of matchSignals(openTasksOf(phases, runningPhaseId), signals)) {
|
|
34
57
|
if (task === null) {
|
|
35
58
|
unplanned.push({ text: terminalSafe(signal.text), agent: nameOf(signal.agent) })
|
|
36
59
|
continue
|