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
|
@@ -19,6 +19,7 @@ commit, push or merge without the user's approval.
|
|
|
19
19
|
**A small fix runs without a lane:** one pull request, no plan, no migration, no write against a shared
|
|
20
20
|
environment. An `implement` subagent builds it; you take its git steps on a short branch (§ 0), under the
|
|
21
21
|
same approval (§ 5), and record it in one line of the board's `{{kit.heading.history}}` instead of a lane row and a plan.
|
|
22
|
+
A small fix that builds a phase of a plan gets a row in the board's `{{kit.heading.lanes}}` table (name, plan link, phase), so the herd shows it; without a phase it stays a history line.
|
|
22
23
|
|
|
23
24
|
> Lanes run `/implement`, not this skill. Never paste these rules into a lane prompt.
|
|
24
25
|
|
|
@@ -88,6 +89,9 @@ Always contains:
|
|
|
88
89
|
- what the lane must not do: push a database migration and any write against a shared environment
|
|
89
90
|
(stage, production) without the user's approval, `git reset` or `git stash` on shared refs
|
|
90
91
|
- "ask before every commit (`/pre-commit`); no push, PR or merge without approval"
|
|
92
|
+
- "before you start a task, mark it in progress in your own task list; without one, run `jh task start
|
|
93
|
+
"<task text>"`, and `jh task done` when it is finished": the herd shows the task you work on from that
|
|
94
|
+
signal
|
|
91
95
|
- "a new phase starts with its skeleton in the plan (heading, origin, tasks, review gate) before any
|
|
92
96
|
research, so the herd never shows it as not in the plan"
|
|
93
97
|
- "the report at the end of each step goes to `.claude/handover/<lane>-report.md` in the worktree,
|
|
@@ -410,13 +414,14 @@ On wake-up:
|
|
|
410
414
|
| `deploy …` | Result and target as the run states them, to the user; a target `unknown (…)` is reported as unknown, never guessed. A failed run goes to the user as an `AskUserQuestion`: read the logs now or later |
|
|
411
415
|
| `due <when> · <action>` | An open appointment fell due (**An agreed time**, below); the action is cut to 160 characters, the board row holds it whole. Carry it out as agreed, under the approvals it needs (§ 5), then write the outcome into its status cell in the same turn |
|
|
412
416
|
| `appointment unreadable <when> · <action>` | An open appointment row whose time cannot be read, or a table without a column the watcher needs (`missing columns: …`). Correct the row or the header in the board; until then the appointment never falls due |
|
|
413
|
-
| `heartbeat` | One line of status to the user, also when nothing changed |
|
|
417
|
+
| `heartbeat` | One line of status to the user, also when nothing changed; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): remind the user of those items in that line |
|
|
414
418
|
| `gh unavailable` | Tell the user once; herdr watching continues |
|
|
415
419
|
| `lane <name> warn` | One line to the user: the lane's fill and that it hands over at a phase boundary if it rises further; no step in the lane |
|
|
416
420
|
| `lane <name> alert` | § 8: send the lane once the request for its hand-over at the end of its current phase |
|
|
417
421
|
| `approval <name> <id> <tool> · <call>` | A call of the lane waits for the user's approval (§ 4, Approvals); the duty line names the question's mark and labels |
|
|
418
422
|
| `model <name> <id> · board <column>` | The lane's last answer came from a model its board row's model column does not name (every model the column names fits). Switch it while it is idle (§ 2 step 6). A column that is wrong goes to the user instead. The watcher reports a deviation once and again only when the model changes |
|
|
419
423
|
| `parallel <name> P<a> ∥ P<b>` | The lane's plan has ready phases whose `{{kit.heading.writes}}` sets do not overlap and that share no `{{kit.heading.serialMarker}}`, read from the plan's `{{kit.heading.dependencies}}` table and its ticks. Propose them to the user in one line, as parallel lanes (§ 2) or as parallel subagents in one lane (`/implement` Step 0b); nothing starts on the event alone. Before proposing, check what the table cannot see: two phases that collide in content stay your judgement. The watcher reports a set once and again only when the set changes |
|
|
424
|
+
| `parallel tasks <name>: "<a>" ∥ "<b>" · ~<n>k tokens for <m> extra agents` | Open tasks that name paths overlapping neither each other nor what the running agents wrote lately or report (your own subagents in the lane's worktree count as running), at a cost under `watch.parallelTasks.maxTokens`; one free task beside a running agent counts. Once per changed situation, at a heartbeat. Nothing starts on the line. Put it to the user at once, never into an open dialog (right after it), the user's note read first: at most three plain lines (which tasks, why they do not collide, the cost: model and token baseline of a new agent, and what they wait for otherwise; no invented time saving), then one `AskUserQuestion` of at most 15 words, "start in parallel" (recommended only under the threshold; `Parallel starten` / `Nacheinander lassen` for a German user) or "keep in sequence". Until the answer it is a `?` row in the user's answers section (**Anything that waits on the user's answer**). No second proposal for the same situation after "keep in sequence". Two tasks that collide in content stay your judgement |
|
|
420
425
|
| `coordinator warn` / `coordinator alert` | § 8, for yourself |
|
|
421
426
|
| `lane added: <name>` | Nothing: the lane is watched. Check that it has its row (§ 2 step 5); a name `<lane>-<purpose>` without a worktree is a helper agent of that lane (§ 1) and needs no row. One the lane did not announce goes to the user |
|
|
422
427
|
| `lane removed: <name>` | Nothing after you closed it (§ 7). Otherwise its agent exited or its worktree is gone: read why, then tell the user |
|
|
@@ -453,6 +458,20 @@ watcher reports an open row once when it falls due (`due`, below); an empty stat
|
|
|
453
458
|
and a reworded or moved row is a new appointment and reports again. Close a reported row through its
|
|
454
459
|
status cell.
|
|
455
460
|
|
|
461
|
+
**Anything that waits on the user's answer** (a preview or a file sent to them, a question put to them, a
|
|
462
|
+
report handed over for acceptance) goes into the board at once, in the section `## Wartet auf dich` (German
|
|
463
|
+
plans) or `## Waiting for you` (English), one row `Since | Item | Origin | Status` (`Seit | Punkt | Herkunft |
|
|
464
|
+
Stand`) with the time as in **An agreed time**. The section survives a hand-over, which a message in this chat
|
|
465
|
+
does not. Enter the row in the same turn you send the thing, not when you notice it was missed. The herd shows
|
|
466
|
+
every open row on top as `for you`, and the watcher repeats them in each heartbeat and status until the
|
|
467
|
+
row is closed. Close a row in its status cell in the turn the user's answer arrives (also when the answer is "no"),
|
|
468
|
+
or when the thing is withdrawn; an empty status counts as open. A file sent with `SendUserFile` and an Artifact you publish need no row of your own: the kit hook
|
|
469
|
+
`sent-file` records each in `.jackherd/sent/`, and the herd, the heartbeat and the status line list the open
|
|
470
|
+
ones next to the board's rows (by file name, without an id). The herd also shows them as 📎 badges on the lane
|
|
471
|
+
they belong to. Close them after the answer with `jh waiting close <id>` (or `--all`); `jh waiting` lists
|
|
472
|
+
what is open, with the ids. A question that is answered in a dialog needs no row, since the dialog is the wait. A watcher event or a lane's report is no row either, unless the answer to it is
|
|
473
|
+
the user's.
|
|
474
|
+
|
|
456
475
|
**A new idea during a release** (the user's or your own, while a plan's phases run toward one
|
|
457
476
|
release) is a line in the plan's `## Follow-ups` section, with its origin, and one line to the user:
|
|
458
477
|
no dialog, no new phase. It enters the release at once only when the release would otherwise be
|
|
@@ -521,15 +540,21 @@ Question: Retry the failed check, or leave it?
|
|
|
521
540
|
```
|
|
522
541
|
|
|
523
542
|
**A draft for review.** A lane's draft that the user reviews (a README, a page, a plan) goes in
|
|
524
|
-
rounds. Show it where the user can read it
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
543
|
+
rounds. Show it where the user can read it. When this session has the `Artifact` tool and `preview.publish`
|
|
544
|
+
in `.jackherd/config.json` is not `false` (default `true`), render the draft with `jh preview <file>`
|
|
545
|
+
(GitHub and npm side by side, external images embedded) and publish that page as a private Artifact; every
|
|
546
|
+
later round updates the same URL (`url` on the publish call), so the user keeps one link. Without the tool
|
|
547
|
+
(another provider, Codex) or with `preview.publish` off, send the page as a file card (`SendUserFile`) or
|
|
548
|
+
tell the user to run `jh preview --open`; a bare file path is the last resort. Ask through
|
|
549
|
+
`AskUserQuestion` with `multiSelect`, one option per remark, send the chosen remarks to the lane, and
|
|
550
|
+
show the same place again once the lane has changed it. The push waits for the approval of the last round.
|
|
528
551
|
|
|
529
552
|
**Approvals.** In a lane, a call that would open a permission dialog (an ask rule) is held by the
|
|
530
553
|
approval gate hook (a project install ships it; a global install has none, and the dialog stays in
|
|
531
554
|
the lane). The hook records the call, the watcher reports `approval <lane> <id> <tool> · <call>`, and
|
|
532
|
-
it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer.
|
|
555
|
+
it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer. A subagent of your own
|
|
556
|
+
session (an `implement` run you started) is held the same way, under the name `subagent-<type>-<id>`
|
|
557
|
+
instead of a lane's; its dialog would otherwise open in your pane and go unnoticed. Ask at once, one
|
|
533
558
|
question per request, through `AskUserQuestion`:
|
|
534
559
|
|
|
535
560
|
- First read the request, `.jackherd/approvals/<id>.json` in the main checkout, and show its `tool`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jackherd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"claude-code",
|
|
6
6
|
"coding-agents",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"!docs/gen",
|
|
49
49
|
"assets",
|
|
50
50
|
"!assets/icons/build.ts",
|
|
51
|
+
"!assets/icons/version.ts",
|
|
52
|
+
"!assets/branding/outline.ts",
|
|
51
53
|
"recipes",
|
|
52
54
|
"README.md",
|
|
53
55
|
"INSTALL.md",
|
|
@@ -178,6 +178,17 @@
|
|
|
178
178
|
},
|
|
179
179
|
"additionalProperties": false
|
|
180
180
|
},
|
|
181
|
+
"preview": {
|
|
182
|
+
"default": {},
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {
|
|
185
|
+
"publish": {
|
|
186
|
+
"default": true,
|
|
187
|
+
"type": "boolean"
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"additionalProperties": false
|
|
191
|
+
},
|
|
181
192
|
"plans": {
|
|
182
193
|
"default": {},
|
|
183
194
|
"type": "object",
|
|
@@ -270,13 +281,13 @@
|
|
|
270
281
|
"type": "object",
|
|
271
282
|
"properties": {
|
|
272
283
|
"warnTokens": {
|
|
273
|
-
"default":
|
|
284
|
+
"default": 350000,
|
|
274
285
|
"type": "integer",
|
|
275
286
|
"exclusiveMinimum": 0,
|
|
276
287
|
"maximum": 9007199254740991
|
|
277
288
|
},
|
|
278
289
|
"alertTokens": {
|
|
279
|
-
"default":
|
|
290
|
+
"default": 500000,
|
|
280
291
|
"type": "integer",
|
|
281
292
|
"exclusiveMinimum": 0,
|
|
282
293
|
"maximum": 9007199254740991
|
|
@@ -361,6 +372,19 @@
|
|
|
361
372
|
},
|
|
362
373
|
"additionalProperties": false
|
|
363
374
|
},
|
|
375
|
+
"parallelTasks": {
|
|
376
|
+
"default": {},
|
|
377
|
+
"type": "object",
|
|
378
|
+
"properties": {
|
|
379
|
+
"maxTokens": {
|
|
380
|
+
"default": 100000,
|
|
381
|
+
"type": "integer",
|
|
382
|
+
"exclusiveMinimum": 0,
|
|
383
|
+
"maximum": 9007199254740991
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
"additionalProperties": false
|
|
387
|
+
},
|
|
364
388
|
"previews": {
|
|
365
389
|
"type": "object",
|
|
366
390
|
"properties": {
|
package/src/cli.ts
CHANGED
|
@@ -24,9 +24,12 @@ export const COMMANDS: CommandTable = {
|
|
|
24
24
|
[HERD_COMMAND.name]: () => import('./plans/command'),
|
|
25
25
|
init: () => import('./sync/init-command'),
|
|
26
26
|
[HERD_COMMAND.alias]: () => import('./plans/command'),
|
|
27
|
+
preview: () => import('./preview/command'),
|
|
27
28
|
settled: () => import('./settled/command'),
|
|
28
29
|
sync: () => import('./sync/command'),
|
|
30
|
+
task: () => import('./task-signals/command'),
|
|
29
31
|
update: () => import('./update/command'),
|
|
32
|
+
waiting: () => import('./waiting/command'),
|
|
30
33
|
watch: () => import('./watch/command'),
|
|
31
34
|
}
|
|
32
35
|
|
|
@@ -13,7 +13,11 @@ export type ResolvedConfig = { config: JackherdConfig; notes: string[] }
|
|
|
13
13
|
|
|
14
14
|
/** The line for context thresholds read as tokens from a file that still gives them as a share. */
|
|
15
15
|
const sharesNote = (path: string, notes: readonly string[]): string[] =>
|
|
16
|
-
notes.length > 0
|
|
16
|
+
notes.length > 0
|
|
17
|
+
? [
|
|
18
|
+
`${path} gives context thresholds as a share; \`${CLI_NAME} sync\` or \`${CLI_NAME} update\` writes them as tokens.`,
|
|
19
|
+
]
|
|
20
|
+
: []
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* Parses a config file's raw JSON, filling what it leaves out from the repository (see `withDetected`) and reading
|
package/src/config/schema.ts
CHANGED
|
@@ -118,6 +118,17 @@ const feedbackSchema = z.strictObject({
|
|
|
118
118
|
noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
const DEFAULT_PARALLEL_TASKS_MAX_TOKENS = 100_000
|
|
122
|
+
|
|
123
|
+
const parallelTasksSchema = z.strictObject({
|
|
124
|
+
/**
|
|
125
|
+
* The most the extra agents of a parallel proposal may cost before they do anything: the token baseline of a fresh
|
|
126
|
+
* agent times the agents to add. Above it the watcher does not propose parallel tasks. The baseline is the context
|
|
127
|
+
* the first answer of a lane's session carried.
|
|
128
|
+
*/
|
|
129
|
+
maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
|
|
130
|
+
})
|
|
131
|
+
|
|
121
132
|
const checksSchema = z.strictObject({
|
|
122
133
|
/**
|
|
123
134
|
* Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
|
|
@@ -228,9 +239,9 @@ const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
|
|
|
228
239
|
const coordinatorSchema = z
|
|
229
240
|
.strictObject({
|
|
230
241
|
/** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
|
|
231
|
-
warnTokens: tokenCount.default(
|
|
242
|
+
warnTokens: tokenCount.default(350_000),
|
|
232
243
|
/** Tokens in the coordinator's context from which the watcher reports `coordinator alert`. */
|
|
233
|
-
alertTokens: tokenCount.default(
|
|
244
|
+
alertTokens: tokenCount.default(500_000),
|
|
234
245
|
})
|
|
235
246
|
.refine(warnBelowAlert, WARN_BELOW_ALERT)
|
|
236
247
|
|
|
@@ -410,6 +421,13 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
410
421
|
terminal: z
|
|
411
422
|
.strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
|
|
412
423
|
.prefault({}),
|
|
424
|
+
/** How the coordinator shows a draft for review (`orchestrate` § 4). */
|
|
425
|
+
preview: z
|
|
426
|
+
.strictObject({
|
|
427
|
+
/** Publish the draft as a private Artifact when the session has the tool; `false` sends a file card or `jh preview --open` instead. */
|
|
428
|
+
publish: z.boolean().default(true),
|
|
429
|
+
})
|
|
430
|
+
.prefault({}),
|
|
413
431
|
plans: z
|
|
414
432
|
.strictObject({
|
|
415
433
|
/** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
|
|
@@ -430,6 +448,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
430
448
|
/**
|
|
431
449
|
* Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
|
|
432
450
|
* it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
|
|
451
|
+
* `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
|
|
433
452
|
* `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
|
|
434
453
|
* Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
|
|
435
454
|
* command whose JSON output reports `source` for a new or changed entry.
|
|
@@ -439,6 +458,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
439
458
|
deploy: deploySchema.optional(),
|
|
440
459
|
feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
|
|
441
460
|
checks: checksSchema.prefault({}),
|
|
461
|
+
parallelTasks: parallelTasksSchema.prefault({}),
|
|
442
462
|
previews: previewsSchema.optional(),
|
|
443
463
|
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
444
464
|
})
|
package/src/context/fill.ts
CHANGED
|
@@ -67,3 +67,15 @@ export const contextFill = (lines: readonly string[], windows: ContextWindows):
|
|
|
67
67
|
const window = windowFor(last.model, highest, windows)
|
|
68
68
|
return { tokens: last.tokens, window, share: Math.min(last.tokens / window, 1), model: last.model }
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a fresh agent carries before it has worked: the input of the first main-chain answer in the lines, which holds
|
|
73
|
+
* the system prompt, the project instructions and the first request. Null when no line is a usable answer.
|
|
74
|
+
*/
|
|
75
|
+
export const firstAnswerTokens = (lines: readonly string[]): number | null => {
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
const answer = answerOf(line)
|
|
78
|
+
if (answer) return answer.tokens
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
@@ -2,6 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync, stat
|
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
|
|
5
|
+
import { isRecord } from '../shared/is-record'
|
|
5
6
|
|
|
6
7
|
/** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
|
|
7
8
|
export const TAIL_BYTES = 2 * 1024 * 1024
|
|
@@ -40,6 +41,20 @@ export const readTailLines = (path: string, bytes: number): string[] => {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** 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
|
+
export const readHeadLines = (path: string, bytes: number): string[] => {
|
|
46
|
+
const descriptor = openSync(path, 'r')
|
|
47
|
+
try {
|
|
48
|
+
const { size } = fstatSync(descriptor)
|
|
49
|
+
const buffer = Buffer.alloc(Math.min(size, bytes))
|
|
50
|
+
readSync(descriptor, buffer, 0, buffer.length, 0)
|
|
51
|
+
const lines = buffer.toString('utf8').split('\n')
|
|
52
|
+
return size > bytes ? lines.slice(0, -1) : lines
|
|
53
|
+
} finally {
|
|
54
|
+
closeSync(descriptor)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
/** The tail of a session's log, read only; null when the session has no log or it cannot be read. */
|
|
44
59
|
export const sessionLog = (
|
|
45
60
|
sessionId: string,
|
|
@@ -59,7 +74,7 @@ export type AgentSession = { session: string | null; cwd: string | null }
|
|
|
59
74
|
const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes('"type":"assistant"'))
|
|
60
75
|
|
|
61
76
|
/** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
|
|
62
|
-
const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
|
|
77
|
+
export const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
|
|
63
78
|
|
|
64
79
|
const newestLogOf = (cwd: string, projectsDirectory: string): string | null => {
|
|
65
80
|
const directory = join(projectsDirectory, projectDirectoryOf(cwd))
|
|
@@ -89,3 +104,30 @@ export const agentLog = (
|
|
|
89
104
|
return reported
|
|
90
105
|
}
|
|
91
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The path of the log `agentLog` would read: the reported session's log when it holds an answer, else the newest log
|
|
110
|
+
* of the agent's working directory, else the reported one; null when there is none.
|
|
111
|
+
*/
|
|
112
|
+
export const agentLogPath = (
|
|
113
|
+
{ session, cwd }: AgentSession,
|
|
114
|
+
projectsDirectory: string = defaultProjectsDirectory(),
|
|
115
|
+
): string | null => {
|
|
116
|
+
const reported = session ? sessionLogPath(session, projectsDirectory) : null
|
|
117
|
+
try {
|
|
118
|
+
if (reported && hasAnswer(readTailLines(reported, TAIL_BYTES))) return reported
|
|
119
|
+
return (cwd ? newestLogOf(cwd, projectsDirectory) : null) ?? reported
|
|
120
|
+
} catch {
|
|
121
|
+
return reported
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line of a session log as an object; null for a line that is no JSON or not an object. */
|
|
126
|
+
export const parseLogEntry = (line: string): Record<string, unknown> | null => {
|
|
127
|
+
try {
|
|
128
|
+
const entry: unknown = JSON.parse(line)
|
|
129
|
+
return isRecord(entry) ? entry : null
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { PHASE_ID_PREFIX, phaseLabels } from './lanes'
|
|
2
|
+
import { PHASE_ID_PREFIX, phaseLabels, spannedPhaseIds } from './lanes'
|
|
3
3
|
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
5
|
import { isPhaseFinished } from './progress'
|
|
@@ -40,7 +40,6 @@ const VERSION = /^v?\d+(?:[.\-+][\w.-]*)?$/i
|
|
|
40
40
|
const PACKAGE_SCOPE = /^@[^/]+\//
|
|
41
41
|
/** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
|
|
42
42
|
const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
|
|
43
|
-
const MAX_RANGE = 50
|
|
44
43
|
|
|
45
44
|
const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
46
45
|
|
|
@@ -63,9 +62,7 @@ const splitOutsideParentheses = (cell: string, separator: string): string[] => {
|
|
|
63
62
|
const rangeOf = (entry: string): string[] | null => {
|
|
64
63
|
const match = PHASE_RANGE.exec(entry)
|
|
65
64
|
if (!match) return null
|
|
66
|
-
|
|
67
|
-
if (first > last || last - first >= MAX_RANGE) return []
|
|
68
|
-
return Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
|
|
65
|
+
return spannedPhaseIds(Number(match[1]), Number(match[2]))
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'after' | 'external'> => {
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -32,6 +32,15 @@ export const PHASE_ID_PREFIX = 'P'
|
|
|
32
32
|
/** A phase as plans and reports name it: `P4`. */
|
|
33
33
|
export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
|
|
34
34
|
|
|
35
|
+
/** The widest range a cell may span; a wider one is a typo, not a plan. */
|
|
36
|
+
const MAX_RANGE = 50
|
|
37
|
+
|
|
38
|
+
/** The ids from `first` to `last`, both included; empty when the range runs backwards or spans `MAX_RANGE` or more. */
|
|
39
|
+
export const spannedPhaseIds = (first: number, last: number): string[] =>
|
|
40
|
+
first > last || last - first >= MAX_RANGE
|
|
41
|
+
? []
|
|
42
|
+
: Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
|
|
43
|
+
|
|
35
44
|
/** Phases as plans and reports name them, joined by `separator`: `P3 P4`, `P19 ∥ P20`. */
|
|
36
45
|
export const phaseLabels = (ids: readonly string[], separator: string): string => ids.map(phaseLabel).join(separator)
|
|
37
46
|
|
|
@@ -39,22 +48,32 @@ const PHASES_COLUMN = 3
|
|
|
39
48
|
const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
|
|
40
49
|
const ENTRY_END = new Set([';', '.'])
|
|
41
50
|
|
|
42
|
-
type PhaseIdPatterns = { leading: RegExp; afterConnector: RegExp; connector: RegExp }
|
|
51
|
+
type PhaseIdPatterns = { leading: RegExp; rangeEnd: RegExp; afterConnector: RegExp; connector: RegExp }
|
|
43
52
|
|
|
44
53
|
const phaseIdPatterns = (language: PlanLanguage): PhaseIdPatterns => {
|
|
45
54
|
const connectors = PLAN_VOCABULARY[language].phaseConnectors.join('|')
|
|
46
55
|
const id = `(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`
|
|
47
56
|
return {
|
|
48
57
|
leading: new RegExp(`^\\s*(?:(?:${connectors})\\s+)*${id}`),
|
|
58
|
+
rangeEnd: new RegExp(`^\\s*[–-]\\s*(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+)\\b`),
|
|
49
59
|
afterConnector: new RegExp(`^\\s*(?:${connectors})\\s`),
|
|
50
60
|
connector: new RegExp(`\\b(?:${connectors})\\s+${id}`, 'g'),
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/** The ids an entry starting with `leading` names: the one id, or every id of a range `P17–P25` it opens. */
|
|
65
|
+
const leadingIds = (clause: string, leading: RegExpMatchArray, patterns: PhaseIdPatterns): string[] => {
|
|
66
|
+
const first = leading[1]
|
|
67
|
+
const end = clause.slice(leading[0].length).match(patterns.rangeEnd)
|
|
68
|
+
if (!end || !/^\d+$/.test(first)) return [first]
|
|
69
|
+
const spanned = spannedPhaseIds(Number(first), Number(end[1]))
|
|
70
|
+
return spanned.length > 0 ? spanned : [first]
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
/**
|
|
55
74
|
* The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
|
|
56
75
|
* sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
|
|
57
|
-
* with an id is running (`P1 (#<number>), P2 (#<number>)`). A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
|
|
76
|
+
* 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,
|
|
58
77
|
* P6 startet neu`) is prose.
|
|
59
78
|
*/
|
|
60
79
|
const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
@@ -69,7 +88,7 @@ const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
|
69
88
|
const leading = clause.match(patterns.leading)
|
|
70
89
|
const startsEntry: boolean = entryStart || listRunning || patterns.afterConnector.test(clause)
|
|
71
90
|
const counts: boolean = leading !== null && startsEntry
|
|
72
|
-
if (leading && counts) ids.push(leading
|
|
91
|
+
if (leading && counts) ids.push(...leadingIds(clause, leading, patterns))
|
|
73
92
|
ids.push(...[...clause.matchAll(patterns.connector)].map((match) => match[1]))
|
|
74
93
|
listRunning ||= counts
|
|
75
94
|
}
|
|
@@ -111,7 +130,8 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
|
|
|
111
130
|
/**
|
|
112
131
|
* The lanes of the sprint document's lanes table (`## Spuren` in `de`) in table order. A row whose
|
|
113
132
|
* first cell names a lane in backticks belongs to that lane; a `↳` row without a name continues
|
|
114
|
-
* the lane above it.
|
|
133
|
+
* the lane above it. A cleaned-up lane stays closed through its `↳` rows, but a later row that names it again
|
|
134
|
+
* without the mark starts the lane anew: the closed rows are history and drop out.
|
|
115
135
|
*/
|
|
116
136
|
export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
|
|
117
137
|
const lanes = new Map<string, SprintLane>()
|
|
@@ -123,7 +143,12 @@ export const parseSprintLanes = (markdown: string, language: PlanLanguage): Spri
|
|
|
123
143
|
if (!name) continue
|
|
124
144
|
const port = Number.parseInt(cells[1] ?? '', 10)
|
|
125
145
|
const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
|
|
126
|
-
|
|
146
|
+
const marked = (cells[0] ?? '').includes(laneClosed)
|
|
147
|
+
if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
|
|
148
|
+
lane.closed = false
|
|
149
|
+
lane.steps = []
|
|
150
|
+
}
|
|
151
|
+
lane.closed ||= marked
|
|
127
152
|
lane.steps.push(withoutRepeats(lane, toStep(cells, phaseId)))
|
|
128
153
|
lanes.set(name, lane)
|
|
129
154
|
previous = name
|
|
@@ -26,3 +26,7 @@ const UNSAFE_TERMINAL_TEXT = new RegExp(
|
|
|
26
26
|
* on the terminal. Apply it where such text enters, before any styling is added.
|
|
27
27
|
*/
|
|
28
28
|
export const terminalSafe = (text: string): string => text.replace(UNSAFE_TERMINAL_TEXT, '')
|
|
29
|
+
|
|
30
|
+
/** The text cut to `max` characters with an ellipsis as the last one; text that fits stays as it is. */
|
|
31
|
+
export const ellipsize = (text: string, max: number): string =>
|
|
32
|
+
text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
2
|
+
import { firstTableWithHeader, sectionLines } from './markdown'
|
|
3
|
+
import { terminalSafe } from './terminal-text'
|
|
4
|
+
|
|
5
|
+
/** One row of the board's section of what waits on the user: when it began (as written), what it is, whether it is open. */
|
|
6
|
+
export type WaitingItem = {
|
|
7
|
+
since: string
|
|
8
|
+
item: string
|
|
9
|
+
open: boolean
|
|
10
|
+
/** A `file:` address the item links to, for a sent file; absent for a row of the board. */
|
|
11
|
+
url?: string
|
|
12
|
+
/** `file` for a file or an Artifact that was sent; absent for a question of the board. */
|
|
13
|
+
kind?: 'file'
|
|
14
|
+
/** The id of the record that closes it (`jh waiting close`); not shown. */
|
|
15
|
+
id?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const oneLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
|
|
19
|
+
|
|
20
|
+
/** An empty status counts as open: a row nobody closed is not dropped in silence. */
|
|
21
|
+
const isOpen = (status: string, language: PlanLanguage): boolean => {
|
|
22
|
+
const lower = status.toLowerCase()
|
|
23
|
+
return lower === '' || WAITING_VOCABULARY[language].open.some((word) => lower.startsWith(word))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
|
|
28
|
+
* found by their header names in any order; the origin column is not read. A table without the since, item or status
|
|
29
|
+
* column, a section without a table and a row without an item give nothing.
|
|
30
|
+
*/
|
|
31
|
+
export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
|
|
32
|
+
const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
|
|
33
|
+
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
34
|
+
const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
|
|
35
|
+
const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
|
|
36
|
+
if (positions.includes(-1)) return []
|
|
37
|
+
const [since, item, status] = positions
|
|
38
|
+
return rows.flatMap((cells) => {
|
|
39
|
+
const text = oneLine(cells[item] ?? '')
|
|
40
|
+
return text === ''
|
|
41
|
+
? []
|
|
42
|
+
: [{ since: oneLine(cells[since] ?? ''), item: text, open: isOpen(cells[status] ?? '', language) }]
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The open items, in the order of the board. */
|
|
47
|
+
export const openWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] =>
|
|
48
|
+
readWaitingItems(board, language).filter(({ open }) => open)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { sep } from 'node:path'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
5
|
+
import type { TaskPhase } from './current-task'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A task an agent is working on: its index among the phase's checkboxes, who works on it (null while only one agent
|
|
9
|
+
* is active in the lane), whether the herd only guesses it from words, and when the agent began it (null for a guess).
|
|
10
|
+
*/
|
|
11
|
+
export type ActiveTask = { index: number; agent: string | null; guessed: boolean; startedAt: number | null }
|
|
12
|
+
|
|
13
|
+
/** A task an agent reports that no open task of the plan names. */
|
|
14
|
+
export type UnplannedTask = { text: string; agent: string | null }
|
|
15
|
+
|
|
16
|
+
export type Assignment = { byPhase: ReadonlyMap<string, ActiveTask[]>; unplanned: UnplannedTask[] }
|
|
17
|
+
|
|
18
|
+
const openTasksOf = (phases: readonly TaskPhase[]): OpenPlanTask[] =>
|
|
19
|
+
phases.flatMap(({ id, items }) =>
|
|
20
|
+
items.flatMap((item, taskIndex) => (item.done ? [] : [{ phaseId: id, taskIndex, text: item.text }])),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 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. Agent text is
|
|
26
|
+
* made terminal safe here, and the agent is named only when more than one agent reports.
|
|
27
|
+
*/
|
|
28
|
+
export const assignSignals = (phases: readonly TaskPhase[], signals: readonly TaskSignal[]): Assignment => {
|
|
29
|
+
const named = new Set(signals.map(({ agent }) => terminalSafe(agent))).size > 1
|
|
30
|
+
const nameOf = (agent: string): string | null => (named ? terminalSafe(agent) : null)
|
|
31
|
+
const byPhase = new Map<string, ActiveTask[]>()
|
|
32
|
+
const unplanned: UnplannedTask[] = []
|
|
33
|
+
for (const { signal, task } of matchSignals(openTasksOf(phases), signals)) {
|
|
34
|
+
if (task === null) {
|
|
35
|
+
unplanned.push({ text: terminalSafe(signal.text), agent: nameOf(signal.agent) })
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
const tasks = byPhase.get(task.phaseId) ?? []
|
|
39
|
+
const shared = tasks.find(({ index }) => index === task.taskIndex)
|
|
40
|
+
const agent = nameOf(signal.agent)
|
|
41
|
+
if (shared) shared.agent = shared.agent && agent ? `${shared.agent}, ${agent}` : shared.agent
|
|
42
|
+
else tasks.push({ index: task.taskIndex, agent, guessed: false, startedAt: signal.startedAt })
|
|
43
|
+
byPhase.set(task.phaseId, tasks)
|
|
44
|
+
}
|
|
45
|
+
return { byPhase, unplanned }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The signals still believed: a `jh task` record counts only while it is younger than the lane's last tick, since a
|
|
50
|
+
* ticked box is the better witness that the agent moved on; Claude's own task list closes itself. Without a tick, all stay.
|
|
51
|
+
*/
|
|
52
|
+
export const freshSignals = (signals: readonly TaskSignal[], lastTickAt: number | null): TaskSignal[] =>
|
|
53
|
+
signals.filter(({ source, startedAt }) => source !== 'jh' || lastTickAt === null || startedAt >= lastTickAt)
|
|
54
|
+
|
|
55
|
+
/** The lane a directory belongs to: the first directory below `worktrees` it lies in; null anywhere else. */
|
|
56
|
+
export const laneOfDirectory = (directory: string, worktrees: string): string | null => {
|
|
57
|
+
const prefix = worktrees.endsWith(sep) ? worktrees : `${worktrees}${sep}`
|
|
58
|
+
if (!directory.startsWith(prefix)) return null
|
|
59
|
+
return directory.slice(prefix.length).split(sep)[0] || null
|
|
60
|
+
}
|
|
@@ -6,7 +6,7 @@ export type PhaseRef = { planHref: string; phaseId: string }
|
|
|
6
6
|
/** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
|
|
7
7
|
export type TickSnapshot = Record<string, Record<string, string>>
|
|
8
8
|
|
|
9
|
-
/** When a phase
|
|
9
|
+
/** When a phase was last ticked or unticked, in milliseconds, by `activityKey`. */
|
|
10
10
|
export type ActivityTimes = Readonly<Record<string, number>>
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -54,20 +54,30 @@ export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRe
|
|
|
54
54
|
}),
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
const tickCount = (ticks: string): number => [...ticks].filter((state) => state === '1').length
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The phases whose number of ticks changed between two reads of a plan `previous` read already, a phase new in `next`
|
|
61
|
+
* counted from none: a tick is activity, task lines written into a phase are not, so phases a lane only wrote down
|
|
62
|
+
* never run (17:10). A phase remembered in the format before D67 has no state to move from.
|
|
63
|
+
*/
|
|
64
|
+
const tickedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
|
|
59
65
|
Object.entries(next).flatMap(([planHref, phases]) => {
|
|
60
66
|
const before = previous[planHref]
|
|
61
67
|
if (before === undefined) return []
|
|
62
|
-
return Object.
|
|
68
|
+
return Object.entries(phases).flatMap(([phaseId, ticks]) => {
|
|
69
|
+
const old = before[phaseId]
|
|
70
|
+
if (old !== undefined && !old.startsWith(TASK_TICKS)) return []
|
|
71
|
+
return tickCount(old ?? '') === tickCount(ticks) ? [] : [{ planHref, phaseId }]
|
|
72
|
+
})
|
|
63
73
|
})
|
|
64
74
|
|
|
65
75
|
/** When each plan file was last written, in milliseconds, by plan link; a plan without an entry is bounded by the clock. */
|
|
66
76
|
export type PlanModified = Readonly<Record<string, number>>
|
|
67
77
|
|
|
68
78
|
/**
|
|
69
|
-
* When each phase last
|
|
70
|
-
* since `previous
|
|
79
|
+
* When each phase was last ticked, after a read at `now` whose ticks are `ticks`: a phase whose number of ticks changed
|
|
80
|
+
* since `previous` (`tickedPhases`), a new one in a plan `previous` read already included, takes the time its plan file was last written
|
|
71
81
|
* (`modified`), bounded by `now`, so a change first seen long after it was made is not taken for a fresh one; the
|
|
72
82
|
* others keep their time. The first read knows no movement, so without a remembered read no phase has a time.
|
|
73
83
|
*/
|
|
@@ -78,7 +88,7 @@ export const nextActivity = (
|
|
|
78
88
|
modified: PlanModified = {},
|
|
79
89
|
): ActivityTimes => {
|
|
80
90
|
if (!previous) return {}
|
|
81
|
-
const changed =
|
|
91
|
+
const changed = tickedPhases(previous.ticks, ticks)
|
|
82
92
|
return {
|
|
83
93
|
...previous.activity,
|
|
84
94
|
...Object.fromEntries(
|