jackherd 1.8.1 → 1.10.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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- 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/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -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 +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +21 -4
- package/kit/hooks/statusline.sh +1 -1
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +23 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +131 -0
- package/src/plans/subagents.ts +190 -0
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -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-location.ts +89 -0
- package/src/shared/private-records.ts +48 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -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 +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- 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/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -5,27 +5,33 @@ import { type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
|
5
5
|
import { WATCH_EVENT } from './watch-events'
|
|
6
6
|
|
|
7
7
|
const SEPARATOR = ' · '
|
|
8
|
+
const RESTING: ReadonlySet<string> = new Set([AGENT_STATUS.idle, AGENT_STATUS.done])
|
|
8
9
|
|
|
9
|
-
/** `lane status`, a working lane with its
|
|
10
|
-
const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes): string => {
|
|
10
|
+
/** `lane status`, a working lane with its progress, a resting one with what it waits on: `lane idle: waits on: …`. */
|
|
11
|
+
const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes, rests: LaneNotes): string => {
|
|
11
12
|
const status = current[lane]?.status ?? NO_AGENT
|
|
12
|
-
const note = status === AGENT_STATUS.working ? notes[lane] : undefined
|
|
13
|
+
const note = status === AGENT_STATUS.working ? notes[lane] : RESTING.has(status) ? rests[lane] : undefined
|
|
13
14
|
const head = `${terminalSafe(lane)} ${terminalSafe(status)}`
|
|
14
15
|
return note === undefined ? head : `${head}: ${terminalSafe(note).replaceAll(SEPARATOR, ' / ')}`
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
|
|
19
|
-
* message of its own: `=== heartbeat <time> · <lane> <status> · …`.
|
|
20
|
+
* message of its own: `=== heartbeat <time> · <lane> <status> · …`. `standing` are the lines that stay until
|
|
21
|
+
* their cause is gone (what waits on the user, the main checkout behind its base), each one entry at the end.
|
|
20
22
|
*/
|
|
21
23
|
export const streamHeartbeatLine = (
|
|
22
24
|
time: string,
|
|
23
25
|
lanes: readonly string[],
|
|
24
26
|
current: LaneSnapshot,
|
|
25
27
|
notes: LaneNotes,
|
|
28
|
+
rests: LaneNotes = {},
|
|
29
|
+
standing: readonly string[] = [],
|
|
26
30
|
): string => {
|
|
27
|
-
const states = lanes.map((lane) => laneState(lane, current, notes))
|
|
28
|
-
return [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
const states = lanes.map((lane) => laneState(lane, current, notes, rests))
|
|
32
|
+
return [
|
|
33
|
+
`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`,
|
|
34
|
+
...(states.length > 0 ? states : ['no lanes']),
|
|
35
|
+
...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
|
|
36
|
+
].join(SEPARATOR)
|
|
31
37
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { ALWAYS_ASK_PATHS, ENV_PATHS, MIGRATION_PATHS } from '../../kit/hooks/hooks/merge-guard/always-ask'
|
|
2
|
+
import { MAX_AUTO_WINDOW_DAYS, windowDays } from '../config/auto-window'
|
|
3
|
+
import type { JackherdConfig } from '../config/schema'
|
|
4
|
+
import { twoDigits } from '../plan-format/clock'
|
|
5
|
+
import {
|
|
6
|
+
AUTO_VERDICT,
|
|
7
|
+
type AutoVerdict,
|
|
8
|
+
GLOB_CHARACTER,
|
|
9
|
+
pathsOfTask,
|
|
10
|
+
type TaskParallelInput,
|
|
11
|
+
type TaskParallelProposal,
|
|
12
|
+
} from './task-parallel'
|
|
13
|
+
|
|
14
|
+
/** The window of `watch.parallelTasks.auto`: two `YYYY-MM-DD` days, both included. */
|
|
15
|
+
export type AutoWindow = NonNullable<JackherdConfig['watch']['parallelTasks']['auto']>
|
|
16
|
+
|
|
17
|
+
/** The switch, unset when the project has none, and every path a task may not name to start without a question. */
|
|
18
|
+
export type AutoSettings = {
|
|
19
|
+
auto?: AutoWindow
|
|
20
|
+
askPaths: readonly string[]
|
|
21
|
+
/** `watch.parallelTasks.maxTokens` of the same config as the switch. */
|
|
22
|
+
maxTokens: number
|
|
23
|
+
/** Whether the repository holds a path of `DATA_PATHS` at or below a directory, relative to its root. */
|
|
24
|
+
holdsDataPath: (directory: string) => boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MAX_AUTO_EXTRA_AGENTS = 2
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The paths that always ask whose work takes effect before the merge: a migration may be pushed and a secret written
|
|
31
|
+
* while the pull request is open, so the merge guard comes too late. A directory of a writes set that holds one asks.
|
|
32
|
+
*/
|
|
33
|
+
export const DATA_PATHS: readonly string[] = [...MIGRATION_PATHS, ...ENV_PATHS]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The paths that always ask (`ALWAYS_ASK_PATHS`, plus every package.json and CHANGELOG.md, which the merge guard
|
|
37
|
+
* reads apart): work on them is tier C and never starts without a question. The project's `guard.alwaysAsk` come on top.
|
|
38
|
+
*/
|
|
39
|
+
export const AUTO_ASK_PATHS: readonly string[] = [...ALWAYS_ASK_PATHS, '**/package.json', '**/CHANGELOG.md']
|
|
40
|
+
|
|
41
|
+
const PROBE = 'x'
|
|
42
|
+
/** Shorter literal pieces of a pattern (`**`, `*`) say nothing about a task's text. */
|
|
43
|
+
const MIN_FRAGMENT = 3
|
|
44
|
+
/**
|
|
45
|
+
* A path that may leave the repository or hide where it lies: absolute, from home, a variable, a drive, a backslash, or
|
|
46
|
+
* a `.` or `..` segment.
|
|
47
|
+
*/
|
|
48
|
+
const OUTSIDE_REPOSITORY = /^[/~]|^[A-Za-z]:|[\\%$]|(?:^|\/)\.{1,2}(?:\/|$)/
|
|
49
|
+
|
|
50
|
+
/** Whether a path may leave the repository or hide where it lies (`OUTSIDE_REPOSITORY`). */
|
|
51
|
+
export const leavesRepository = (path: string): boolean => OUTSIDE_REPOSITORY.test(path)
|
|
52
|
+
|
|
53
|
+
const hasFileName = (path: string): boolean => /\.[^/]*$/.test(path.slice(path.lastIndexOf('/') + 1))
|
|
54
|
+
|
|
55
|
+
/** A path up to the directory its first wildcard lies in; a path without one is itself. */
|
|
56
|
+
const literalPrefix = (path: string): string => {
|
|
57
|
+
const glob = path.search(GLOB_CHARACTER)
|
|
58
|
+
return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether one path lies inside the other or both are the same; `''` is the whole repository. */
|
|
62
|
+
const nested = (left: string, right: string): boolean =>
|
|
63
|
+
left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
64
|
+
|
|
65
|
+
const matchesAnyPattern = (path: string, patterns: readonly string[]): boolean => {
|
|
66
|
+
const lower = path.toLowerCase()
|
|
67
|
+
const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
|
|
68
|
+
return globs.some((glob) => glob.match(lower) || glob.match(`${lower}/${PROBE}`))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a path a task names may touch a path that always asks. Only a named file inside a directory of the
|
|
73
|
+
* repository is matched (in lower case, as the merge guard does): a path that may leave the repository, a directory,
|
|
74
|
+
* a glob or a bare file name could reach such a path somewhere, so it counts as one. A dotted name is also matched as
|
|
75
|
+
* a directory, so `.claude` counts like `.claude/**`.
|
|
76
|
+
*/
|
|
77
|
+
export const touchesAskPath = (path: string, patterns: readonly string[]): boolean => {
|
|
78
|
+
if (OUTSIDE_REPOSITORY.test(path) || !path.includes('/') || GLOB_CHARACTER.test(path) || !hasFileName(path))
|
|
79
|
+
return true
|
|
80
|
+
return matchesAnyPattern(path, patterns)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a path relative to the repository root is, or lies in, one of `DATA_PATHS`. */
|
|
84
|
+
export const isDataPath = (path: string): boolean => matchesAnyPattern(path, DATA_PATHS)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether an entry of a phase's writes set may touch a path that always asks: the whole repository, a path that may
|
|
88
|
+
* leave it, an entry a pattern matches as written (`packages/*\/package.json`), a directory or glob that overlaps a
|
|
89
|
+
* pattern anchored at the root (`.claude/**`), or one under whose literal part the repository holds a migration or an
|
|
90
|
+
* env file (`holdsDataPath`). Other patterns that hold anywhere (`**\/package.json`) are not looked for below a
|
|
91
|
+
* directory: their work waits for the merge guard.
|
|
92
|
+
*/
|
|
93
|
+
export const writesTouchAskPath = (
|
|
94
|
+
entry: string,
|
|
95
|
+
patterns: readonly string[],
|
|
96
|
+
holdsDataPath: (directory: string) => boolean,
|
|
97
|
+
): boolean => {
|
|
98
|
+
if (entry === '' || OUTSIDE_REPOSITORY.test(entry)) return true
|
|
99
|
+
if (matchesAnyPattern(entry, patterns)) return true
|
|
100
|
+
if (!GLOB_CHARACTER.test(entry) && hasFileName(entry)) return false
|
|
101
|
+
const literal = literalPrefix(entry.toLowerCase())
|
|
102
|
+
const rootAnchored = patterns.filter((pattern) => !pattern.startsWith('**'))
|
|
103
|
+
return (
|
|
104
|
+
rootAnchored.some((pattern) => nested(literal, literalPrefix(pattern.toLowerCase()))) ||
|
|
105
|
+
holdsDataPath(literalPrefix(entry))
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The literal pieces of the patterns (`.claude`, `package.json`, `migrations`), in lower case. */
|
|
110
|
+
const fragmentsOf = (patterns: readonly string[]): string[] => [
|
|
111
|
+
...new Set(
|
|
112
|
+
patterns.flatMap((pattern) =>
|
|
113
|
+
pattern
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.split(/[/*?[\]{}]+/)
|
|
116
|
+
.filter((piece) => piece.length >= MIN_FRAGMENT),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/** The local calendar day of `at`, as `YYYY-MM-DD`. */
|
|
122
|
+
export const localDay = (at: number): string => {
|
|
123
|
+
const date = new Date(at)
|
|
124
|
+
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const windowRefusal = (auto: AutoWindow, today: string): string | null => {
|
|
128
|
+
if (!(windowDays(auto.since, auto.until) <= MAX_AUTO_WINDOW_DAYS))
|
|
129
|
+
return `window longer than ${MAX_AUTO_WINDOW_DAYS} days`
|
|
130
|
+
if (today < auto.since) return `off until ${auto.since}`
|
|
131
|
+
if (today > auto.until) return `ended ${auto.until}`
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const extraAgentsAtOnce = (input: TaskParallelInput, proposal: TaskParallelProposal): number =>
|
|
136
|
+
Math.max(input.active.length - 1, 0) + proposal.extraAgents
|
|
137
|
+
|
|
138
|
+
const taskPathRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
|
|
139
|
+
const path = proposal.tasks.flatMap((task) => pathsOfTask(task.text)).find((entry) => touchesAskPath(entry, patterns))
|
|
140
|
+
return path === undefined ? null : `a task names ${path}, which always asks`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A task's text may name such a path without backticks or extension (`.husky/pre-commit`, `CODEOWNERS`). */
|
|
144
|
+
const taskTextRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
|
|
145
|
+
const fragments = fragmentsOf(patterns)
|
|
146
|
+
const fragment = proposal.tasks
|
|
147
|
+
.map((task) => task.text.toLowerCase())
|
|
148
|
+
.flatMap((text) => fragments.filter((piece) => text.includes(piece)))[0]
|
|
149
|
+
return fragment === undefined ? null : `a task mentions ${fragment}, which always asks`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The task's text does not bind the subagent; its phase's writes set is what it may write, read from the plan at the
|
|
154
|
+
* base branch, so the lane's own copy widens nothing. A phase the base branch does not have asks.
|
|
155
|
+
*/
|
|
156
|
+
const phaseWritesRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
|
|
157
|
+
for (const task of proposal.tasks) {
|
|
158
|
+
const writes = input.baseWrites[task.phase]
|
|
159
|
+
if (writes === undefined || writes.length === 0) return `phase ${task.phase} has no writes set on the base branch`
|
|
160
|
+
const entry = writes.find((path) => writesTouchAskPath(path, settings.askPaths, settings.holdsDataPath))
|
|
161
|
+
if (entry !== undefined)
|
|
162
|
+
return `phase ${task.phase} writes ${entry === '' ? 'the whole repository' : entry}, which always asks`
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const limitRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
|
|
168
|
+
const { askPaths: patterns, maxTokens } = settings
|
|
169
|
+
if (input.active.length === 0) return 'no running work in the lane'
|
|
170
|
+
const atOnce = extraAgentsAtOnce(input, proposal)
|
|
171
|
+
if (atOnce > MAX_AUTO_EXTRA_AGENTS) return `${atOnce} extra agents at once, at most ${MAX_AUTO_EXTRA_AGENTS}`
|
|
172
|
+
if (proposal.estimateTokens >= Math.min(input.thresholdTokens, maxTokens)) return 'over the token limit'
|
|
173
|
+
return (
|
|
174
|
+
taskPathRefusal(proposal, patterns) ??
|
|
175
|
+
taskTextRefusal(proposal, patterns) ??
|
|
176
|
+
phaseWritesRefusal(input, proposal, settings)
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the coordinator may start `proposal` without asking: the switch holds on `today`, the lane has running work
|
|
182
|
+
* (the extra agents are its subagents, never a new lane), at most two extra agents run at once with the subagents
|
|
183
|
+
* already running, the cost stays under the limit, and neither a task's paths, its text nor its phase's writes set
|
|
184
|
+
* reaches a path that always asks. Every doubt asks.
|
|
185
|
+
*/
|
|
186
|
+
export const autoVerdict = (
|
|
187
|
+
input: TaskParallelInput,
|
|
188
|
+
proposal: TaskParallelProposal,
|
|
189
|
+
settings: AutoSettings,
|
|
190
|
+
today: string,
|
|
191
|
+
): AutoVerdict => {
|
|
192
|
+
if (settings.auto === undefined) return { kind: AUTO_VERDICT.off }
|
|
193
|
+
const reason = windowRefusal(settings.auto, today) ?? limitRefusal(input, proposal, settings)
|
|
194
|
+
return reason === null ? { kind: AUTO_VERDICT.auto, until: settings.auto.until } : { kind: AUTO_VERDICT.asks, reason }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The line that the switch ended, once per last day: `ended` is the last day already reported (null before any), the
|
|
199
|
+
* answer the one to keep. Nothing while the switch holds or is unset.
|
|
200
|
+
*/
|
|
201
|
+
export const autoEndedReport = (
|
|
202
|
+
ended: string | null,
|
|
203
|
+
auto: AutoWindow | undefined,
|
|
204
|
+
today: string,
|
|
205
|
+
): { ended: string | null; line: string | null } =>
|
|
206
|
+
auto === undefined || today <= auto.until || ended === auto.until
|
|
207
|
+
? { ended, line: null }
|
|
208
|
+
: { ended: auto.until, line: `parallel tasks auto ended ${auto.until}: every proposal asks again` }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isDataPath, leavesRepository } from './task-parallel-auto'
|
|
4
|
+
|
|
5
|
+
type Child = { path: string; searched: boolean }
|
|
6
|
+
|
|
7
|
+
/** Directories a search below a writes entry skips: installed packages and the repository's own store. */
|
|
8
|
+
const SKIPPED = new Set(['node_modules', '.git'])
|
|
9
|
+
/** How many entries one search reads before it gives up; a directory larger than this counts as holding one. */
|
|
10
|
+
const MAX_ENTRIES = 20_000
|
|
11
|
+
|
|
12
|
+
/** The children of a directory below the root, none when it cannot be read; `searched` marks a directory to descend. */
|
|
13
|
+
const childrenOf = (root: string, directory: string): Child[] => {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(join(root, directory), { withFileTypes: true }).map((entry) => ({
|
|
16
|
+
path: directory === '' ? entry.name : `${directory}/${entry.name}`,
|
|
17
|
+
searched: entry.isDirectory() && !SKIPPED.has(entry.name),
|
|
18
|
+
}))
|
|
19
|
+
} catch {
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a path below `relative` is a data path; `true` once the search has read more than `limit` entries. */
|
|
25
|
+
const searchBelow = (root: string, relative: string, limit: number): boolean => {
|
|
26
|
+
const pending = [relative]
|
|
27
|
+
let read = 0
|
|
28
|
+
while (pending.length > 0) {
|
|
29
|
+
for (const child of childrenOf(root, pending.pop() ?? '')) {
|
|
30
|
+
read += 1
|
|
31
|
+
if (read > limit || isDataPath(child.path)) return true
|
|
32
|
+
if (child.searched) pending.push(child.path)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `holdsDataPath` for the repository at `root`: whether a migration or an env file lies at or below a directory,
|
|
40
|
+
* read from the file system at each call. A path that may leave the repository counts, a directory that does not exist
|
|
41
|
+
* holds nothing, and a search that reads more than `limit` entries counts as a hit, so a doubt asks.
|
|
42
|
+
*/
|
|
43
|
+
export const holdsDataPathIn =
|
|
44
|
+
(root: string, limit: number = MAX_ENTRIES) =>
|
|
45
|
+
(directory: string): boolean =>
|
|
46
|
+
leavesRepository(directory) || isDataPath(directory) || searchBelow(root, directory, limit)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isPhaseFinished, isPhaseOpen, type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
|
|
2
|
+
import { type PhaseDependency, parseDependencyTable, writeSetOf } from '../plan-format/dependencies'
|
|
3
|
+
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
5
|
+
import type { ActiveWork, OpenTask, TaskParallelInput } from './task-parallel'
|
|
6
|
+
|
|
7
|
+
/** The tasks a lane could start from its plan: open items of the phases that are ready, with the writes of the table. */
|
|
8
|
+
export type PlanTaskView = {
|
|
9
|
+
open: OpenPlanTask[]
|
|
10
|
+
doneCount: number
|
|
11
|
+
/** Phase id → the paths its row writes, `wie N` cells resolved; a phase whose set cannot be resolved has none. */
|
|
12
|
+
phaseWrites: Record<string, string[]>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const phaseWritesOf = (rows: readonly PhaseDependency[]): Record<string, string[]> => {
|
|
16
|
+
const byId = new Map(rows.map((row) => [row.id, row]))
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
rows.flatMap((row) => {
|
|
19
|
+
const writes = writeSetOf(row, byId)
|
|
20
|
+
return writes === null ? [] : [[row.id, writes]]
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isReady = (row: PhaseDependency, doneIds: ReadonlySet<string>): boolean =>
|
|
26
|
+
!doneIds.has(row.id) && row.external.length === 0 && row.after.every((id) => doneIds.has(id))
|
|
27
|
+
|
|
28
|
+
/** The phases whose tasks may start: ready by the dependency table, else the first open phase of a plan without one. */
|
|
29
|
+
const readyPhaseIds = (phases: readonly PlanPhase[], table: readonly PhaseDependency[] | null): ReadonlySet<string> => {
|
|
30
|
+
const doneIds = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
|
|
31
|
+
if (table === null)
|
|
32
|
+
return new Set(
|
|
33
|
+
phases
|
|
34
|
+
.filter(isPhaseOpen)
|
|
35
|
+
.slice(0, 1)
|
|
36
|
+
.map(({ id }) => id),
|
|
37
|
+
)
|
|
38
|
+
return new Set(table.filter((row) => isReady(row, doneIds)).map(({ id }) => id))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const openTasksOf = (phase: PlanPhase): OpenPlanTask[] =>
|
|
42
|
+
phase.items.flatMap((item, taskIndex) =>
|
|
43
|
+
item.done || item.gate ? [] : [{ phaseId: phase.id, taskIndex, text: item.text }],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/** The open tasks a lane could start from `markdown`, its ticked count and the writes of its phases. */
|
|
47
|
+
export const planTaskViewOf = (markdown: string, language: PlanLanguage): PlanTaskView => {
|
|
48
|
+
const phases = parsePlanPhases(markdown, language)
|
|
49
|
+
const table = parseDependencyTable(markdown, language)
|
|
50
|
+
const ready = readyPhaseIds(phases, table)
|
|
51
|
+
return {
|
|
52
|
+
open: phases.filter(({ id }) => ready.has(id)).flatMap(openTasksOf),
|
|
53
|
+
doneCount: phases.reduce((count, phase) => count + phase.items.filter((item) => item.done).length, 0),
|
|
54
|
+
phaseWrites: table === null ? {} : phaseWritesOf(table),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type TaskParallelFacts = {
|
|
59
|
+
lane: string
|
|
60
|
+
plan: PlanTaskView
|
|
61
|
+
/** What agents in the lane's worktree report they work on, the coordinator's subagents included. */
|
|
62
|
+
signals: readonly TaskSignal[]
|
|
63
|
+
/** Files the work in the lane's worktree wrote lately, relative to it. */
|
|
64
|
+
touchedFiles: readonly string[]
|
|
65
|
+
/** The lane's own agent is working, with or without a task report. */
|
|
66
|
+
laneWorking: boolean
|
|
67
|
+
/** The writes sets of the plan at the base branch; empty when the base branch has no such plan. */
|
|
68
|
+
baseWrites: Record<string, string[]>
|
|
69
|
+
baselineTokens: number
|
|
70
|
+
thresholdTokens: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const activeWorkOf = (facts: TaskParallelFacts): ActiveWork[] => {
|
|
74
|
+
const matched = matchSignals(facts.plan.open, facts.signals)
|
|
75
|
+
const work: ActiveWork[] = matched.map(({ signal, task }) => ({
|
|
76
|
+
name: signal.agent,
|
|
77
|
+
files: [],
|
|
78
|
+
taskText: signal.text,
|
|
79
|
+
...(task ? { phase: task.phaseId } : {}),
|
|
80
|
+
}))
|
|
81
|
+
if (work.length === 0 && facts.laneWorking) work.push({ name: facts.lane, files: [] })
|
|
82
|
+
const [first, ...rest] = work
|
|
83
|
+
return first ? [{ ...first, files: [...facts.touchedFiles] }, ...rest] : []
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The tasks a running signal already works on are not open any more. */
|
|
87
|
+
const openTasksWithout = (facts: TaskParallelFacts): OpenTask[] => {
|
|
88
|
+
const taken = new Set(
|
|
89
|
+
matchSignals(facts.plan.open, facts.signals).flatMap(({ task }) =>
|
|
90
|
+
task ? [`${task.phaseId}:${task.taskIndex}`] : [],
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
return facts.plan.open
|
|
94
|
+
.filter((task) => !taken.has(`${task.phaseId}:${task.taskIndex}`))
|
|
95
|
+
.map(({ phaseId, text }) => ({ phase: phaseId, text }))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The input of the pure core from what the watcher read. */
|
|
99
|
+
export const taskParallelInputOf = (facts: TaskParallelFacts): TaskParallelInput => ({
|
|
100
|
+
lane: facts.lane,
|
|
101
|
+
open: openTasksWithout(facts),
|
|
102
|
+
active: activeWorkOf(facts),
|
|
103
|
+
phaseWrites: facts.plan.phaseWrites,
|
|
104
|
+
baseWrites: facts.baseWrites,
|
|
105
|
+
doneCount: facts.plan.doneCount,
|
|
106
|
+
baselineTokens: facts.baselineTokens,
|
|
107
|
+
thresholdTokens: facts.thresholdTokens,
|
|
108
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { firstAnswerTokens } from '../context/fill'
|
|
4
|
+
import { agentLogPath, readHeadLines, readTailLines, TAIL_BYTES } from '../context/session-log'
|
|
5
|
+
import type { SprintLane } from '../plan-format'
|
|
6
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
7
|
+
import { STALE_ACTIVITY_MINUTES } from '../plans/lane-summary'
|
|
8
|
+
import { type PlansSettings, worktreeOf } from '../plans/settings'
|
|
9
|
+
import { readClaudeSignals } from '../task-signals/claude-read'
|
|
10
|
+
import { recordSignals } from '../task-signals/record'
|
|
11
|
+
import { readTaskRecords } from '../task-signals/record-store'
|
|
12
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
13
|
+
import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
|
|
14
|
+
import { baseLanePlanText, watchedLanePlanText } from './lane-progress'
|
|
15
|
+
import type { TaskParallelInput } from './task-parallel'
|
|
16
|
+
import { planTaskViewOf, taskParallelInputOf } from './task-parallel-input'
|
|
17
|
+
import { isWithinWorktree, touchedFilesOf } from './task-parallel-touched'
|
|
18
|
+
|
|
19
|
+
const RECENT_WRITE_MS = 30 * 60_000
|
|
20
|
+
const HEAD_BYTES = 256 * 1024
|
|
21
|
+
const SUBAGENT_LOG = /^agent-[A-Za-z0-9]+\.jsonl$/
|
|
22
|
+
/** How many subagent logs of one session have their writes read, the newest first. */
|
|
23
|
+
const MAX_SUBAGENT_LOGS = 20
|
|
24
|
+
|
|
25
|
+
/** One agent's session: its log, the signals in it and its subagents, and the tails of the logs the writes are read from. */
|
|
26
|
+
type AgentRead = { agent: LaneAgent; logPath: string | null; signals: TaskSignal[]; tails: string[][] }
|
|
27
|
+
|
|
28
|
+
const modifiedAt = (path: string): number => {
|
|
29
|
+
try {
|
|
30
|
+
return statSync(path).mtimeMs
|
|
31
|
+
} catch {
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The newest `MAX_SUBAGENT_LOGS` subagent logs of a session, by modification time, so a long session bounds the read. */
|
|
37
|
+
const subagentLogPaths = (logPath: string): string[] => {
|
|
38
|
+
const directory = join(logPath.replace(/\.jsonl$/, ''), 'subagents')
|
|
39
|
+
try {
|
|
40
|
+
return existsSync(directory)
|
|
41
|
+
? readdirSync(directory)
|
|
42
|
+
.filter((name) => SUBAGENT_LOG.test(name))
|
|
43
|
+
.map((name) => ({ path: join(directory, name), modified: modifiedAt(join(directory, name)) }))
|
|
44
|
+
.sort((left, right) => right.modified - left.modified)
|
|
45
|
+
.slice(0, MAX_SUBAGENT_LOGS)
|
|
46
|
+
.map(({ path }) => path)
|
|
47
|
+
: []
|
|
48
|
+
} catch {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tailOf = (path: string): string[] => {
|
|
54
|
+
try {
|
|
55
|
+
return readTailLines(path, TAIL_BYTES)
|
|
56
|
+
} catch {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const readAgent = (agent: LaneAgent, projectsDirectory: string | undefined, now: number): AgentRead => {
|
|
62
|
+
const logPath = agentLogPath(agent, projectsDirectory)
|
|
63
|
+
const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
|
|
64
|
+
return {
|
|
65
|
+
agent,
|
|
66
|
+
logPath,
|
|
67
|
+
signals: logPath ? readClaudeSignals(logPath, { notOlderThan }) : [],
|
|
68
|
+
tails: logPath ? [logPath, ...subagentLogPaths(logPath)].map(tailOf) : [],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const baselineCache = new Map<string, number | null>()
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a fresh agent carries before it has worked, measured as `jh context` measures a fill: the input of the first
|
|
76
|
+
* answer in a session log (system prompt, project instructions and the first request). The smallest honest source on
|
|
77
|
+
* hand: a log of a started session, not a guess; cached per log, since the head of a log never changes.
|
|
78
|
+
*/
|
|
79
|
+
const baselineOf = (logPath: string): number | null => {
|
|
80
|
+
if (!baselineCache.has(logPath)) {
|
|
81
|
+
try {
|
|
82
|
+
baselineCache.set(logPath, firstAnswerTokens(readHeadLines(logPath, HEAD_BYTES)))
|
|
83
|
+
} catch {
|
|
84
|
+
baselineCache.set(logPath, null)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return baselineCache.get(logPath) ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const baselineFor = (lane: string, reads: readonly AgentRead[]): number | null => {
|
|
91
|
+
const own = reads.find(({ agent }) => agent.name === lane)
|
|
92
|
+
const sources = [own, ...reads.filter((read) => read !== own)]
|
|
93
|
+
for (const read of sources) {
|
|
94
|
+
const tokens = read?.logPath ? baselineOf(read.logPath) : null
|
|
95
|
+
if (tokens !== null) return tokens
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const uniqueSignals = (signals: readonly TaskSignal[]): TaskSignal[] => [
|
|
101
|
+
...new Map(signals.map((signal) => [`${signal.agent}|${signal.text}`, signal])).values(),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
export type TaskParallelSources = {
|
|
105
|
+
root: string
|
|
106
|
+
settings: PlansSettings
|
|
107
|
+
boardFile: string
|
|
108
|
+
thresholdTokens: number
|
|
109
|
+
now: number
|
|
110
|
+
/** Where Claude Code keeps its session logs; the user's by default. */
|
|
111
|
+
projectsDirectory?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The input of the parallel-task check for each of `lanes`, read from the plan of the lane's newest row, the sessions
|
|
116
|
+
* of every listed agent (a lane's work, and the coordinator's subagents in the lane's worktree) and the records of
|
|
117
|
+
* `jh task`. A lane without a plan, without a token baseline, without a task left or without active work (a working
|
|
118
|
+
* agent, a running subagent or an open record in its worktree) is not in the answer.
|
|
119
|
+
*/
|
|
120
|
+
export const readTaskParallelInputs = (
|
|
121
|
+
lanes: readonly SprintLane[],
|
|
122
|
+
agents: readonly LaneAgent[],
|
|
123
|
+
sources: TaskParallelSources,
|
|
124
|
+
): Record<string, TaskParallelInput> => {
|
|
125
|
+
const reads = agents.map((agent) => readAgent(agent, sources.projectsDirectory, sources.now))
|
|
126
|
+
const records = recordSignals(readTaskRecords(sources.root))
|
|
127
|
+
const since = sources.now - RECENT_WRITE_MS
|
|
128
|
+
return Object.fromEntries(
|
|
129
|
+
lanes.flatMap((lane) => {
|
|
130
|
+
const text = watchedLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
|
|
131
|
+
const baselineTokens = baselineFor(lane.name, reads)
|
|
132
|
+
if (text === null || baselineTokens === null) return []
|
|
133
|
+
const worktree = worktreeOf(sources.root, sources.settings, lane.name)
|
|
134
|
+
const signals = uniqueSignals([...reads.flatMap((read) => read.signals), ...records]).filter((signal) =>
|
|
135
|
+
isWithinWorktree(signal.cwd, worktree),
|
|
136
|
+
)
|
|
137
|
+
const baseText = baseLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
|
|
138
|
+
const input = taskParallelInputOf({
|
|
139
|
+
lane: lane.name,
|
|
140
|
+
plan: planTaskViewOf(text, sources.settings.planLanguage),
|
|
141
|
+
baseWrites: baseText === null ? {} : planTaskViewOf(baseText, sources.settings.planLanguage).phaseWrites,
|
|
142
|
+
signals,
|
|
143
|
+
touchedFiles: reads.flatMap((read) => read.tails.flatMap((tail) => touchedFilesOf(tail, worktree, since))),
|
|
144
|
+
laneWorking: agents.some((agent) => agent.name === lane.name && agent.status === AGENT_STATUS.working),
|
|
145
|
+
baselineTokens,
|
|
146
|
+
thresholdTokens: sources.thresholdTokens,
|
|
147
|
+
})
|
|
148
|
+
return input.open.length === 0 || input.active.length === 0 ? [] : [[lane.name, input]]
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { isAbsolute, relative } from 'node:path'
|
|
2
|
+
import { parseLogEntry } from '../context/session-log'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
4
|
+
|
|
5
|
+
const WRITING_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
6
|
+
|
|
7
|
+
const writtenPathsOf = (entry: Record<string, unknown>): string[] => {
|
|
8
|
+
const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
|
|
9
|
+
return content.flatMap((block) => {
|
|
10
|
+
if (!isRecord(block) || block.type !== 'tool_use' || !WRITING_TOOLS.has(String(block.name))) return []
|
|
11
|
+
const input = isRecord(block.input) ? block.input : {}
|
|
12
|
+
const path = input.file_path ?? input.notebook_path
|
|
13
|
+
return typeof path === 'string' ? [path] : []
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const insideWorktree = (path: string, worktree: string): string | null => {
|
|
18
|
+
const inside = relative(worktree, path)
|
|
19
|
+
return inside === '' || inside.startsWith('..') || isAbsolute(inside) ? null : inside
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether `path` is the worktree or lies below it. */
|
|
23
|
+
export const isWithinWorktree = (path: string, worktree: string): boolean =>
|
|
24
|
+
insideWorktree(path, worktree) !== null || path === worktree
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The files the log lines show written (`Edit`, `Write`, `MultiEdit`, `NotebookEdit`) at or after `sinceMs` inside
|
|
28
|
+
* `worktree`, relative to it and without repeats, in order of first write. A path outside the worktree, a line that is
|
|
29
|
+
* no JSON and a write without a time are left out.
|
|
30
|
+
*/
|
|
31
|
+
export const touchedFilesOf = (lines: readonly string[], worktree: string, sinceMs: number): string[] => {
|
|
32
|
+
const touched = lines.flatMap((line) => {
|
|
33
|
+
const entry = parseLogEntry(line)
|
|
34
|
+
const time = typeof entry?.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
35
|
+
if (!entry || Number.isNaN(time) || time < sinceMs) return []
|
|
36
|
+
return writtenPathsOf(entry).flatMap((path) => insideWorktree(path, worktree) ?? [])
|
|
37
|
+
})
|
|
38
|
+
return [...new Set(touched)]
|
|
39
|
+
}
|