jackherd 1.9.0 → 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 +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- 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/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 -187
- 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/store.ts +3 -12
- 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 +13 -5
- 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 +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- 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 +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- 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 +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- 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 +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- 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/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-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -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 +17 -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 +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- 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 +66 -17
- package/src/plans/subagents.ts +64 -6
- 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 +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -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/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- 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/pull-requests.ts +6 -2
- 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/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- 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 +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- 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/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- 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
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { projectDirectoryOf, readTailLines } from '../context/session-log'
|
|
3
|
+
import { defaultProjectsDirectory, projectDirectoryOf, readTailLines } from '../context/session-log'
|
|
4
4
|
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
5
|
+
import { DEFAULT_SKILL_PREFIX } from '../sync/init'
|
|
5
6
|
import { readApprovalRequests } from '../watch/approvals'
|
|
6
7
|
import {
|
|
8
|
+
type RunningSubagent,
|
|
7
9
|
runningSubagents,
|
|
8
10
|
SUBAGENT_STALE_MINUTES,
|
|
9
11
|
type SubagentActivity,
|
|
@@ -19,8 +21,19 @@ export type SubagentSource = {
|
|
|
19
21
|
approvalsRoot: string
|
|
20
22
|
/** The absolute worktree directory. */
|
|
21
23
|
worktreeDirectory: string
|
|
24
|
+
/** The project's `skillPrefix`, which its copies of the review agents carry; without it only the default one counts. */
|
|
25
|
+
skillPrefix?: string
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
/** The source of the main checkout `root`: Claude Code's own session logs, the gate's requests in the checkout itself. */
|
|
29
|
+
export const subagentSourceOf = (root: string, worktreeDirectory: string, skillPrefix?: string): SubagentSource => ({
|
|
30
|
+
projects: defaultProjectsDirectory(),
|
|
31
|
+
root,
|
|
32
|
+
approvalsRoot: root,
|
|
33
|
+
worktreeDirectory,
|
|
34
|
+
skillPrefix,
|
|
35
|
+
})
|
|
36
|
+
|
|
24
37
|
/** The end of a log that holds the directory of the last entries. */
|
|
25
38
|
const TAIL_BYTES = 64 * 1024
|
|
26
39
|
const META_FILE = /^agent-([0-9a-f]+)\.meta\.json$/
|
|
@@ -35,6 +48,15 @@ const directoriesOf = (path: string): string[] => {
|
|
|
35
48
|
}
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
/** The entry names of `path`; none when it is missing or cannot be read. */
|
|
52
|
+
const namesIn = (path: string): string[] => {
|
|
53
|
+
try {
|
|
54
|
+
return readdirSync(path)
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
const logOf = (directory: string, name: string, id: string, now: number): SubagentLog[] => {
|
|
39
61
|
try {
|
|
40
62
|
const logPath = join(directory, `agent-${id}.jsonl`)
|
|
@@ -47,22 +69,39 @@ const logOf = (directory: string, name: string, id: string, now: number): Subage
|
|
|
47
69
|
}
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
export const readSubagentLogs = (projects: string, root: string, now: number): SubagentLog[] => {
|
|
55
|
-
const project = join(projects, projectDirectoryOf(root))
|
|
56
|
-
return directoriesOf(project).flatMap((session) => {
|
|
72
|
+
/** The subagent logs of every session in one project directory. */
|
|
73
|
+
const sessionLogsOf = (project: string, now: number): SubagentLog[] =>
|
|
74
|
+
directoriesOf(project).flatMap((session) => {
|
|
57
75
|
const directory = join(project, session, 'subagents')
|
|
58
|
-
|
|
59
|
-
return readdirSync(directory).flatMap((name) => {
|
|
76
|
+
return namesIn(directory).flatMap((name) => {
|
|
60
77
|
const id = META_FILE.exec(name)?.[1]
|
|
61
78
|
return id ? logOf(directory, name, id, now) : []
|
|
62
79
|
})
|
|
63
80
|
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
|
|
84
|
+
* `worktreeDirectory` is given, in a worktree below it: a lane's own subagents, its reviews among them, live there.
|
|
85
|
+
*/
|
|
86
|
+
const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?: string): string[] => {
|
|
87
|
+
const main = projectDirectoryOf(root)
|
|
88
|
+
const lanes = worktreeDirectory === undefined ? null : `${projectDirectoryOf(worktreeDirectory)}-`
|
|
89
|
+
return directoriesOf(projects).filter((name) => name === main || (lanes !== null && name.startsWith(lanes)))
|
|
64
90
|
}
|
|
65
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
|
|
94
|
+
* from Claude Code's `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read
|
|
95
|
+
* is left out.
|
|
96
|
+
*/
|
|
97
|
+
export const readSubagentLogs = (
|
|
98
|
+
projects: string,
|
|
99
|
+
root: string,
|
|
100
|
+
now: number,
|
|
101
|
+
worktreeDirectory?: string,
|
|
102
|
+
): SubagentLog[] =>
|
|
103
|
+
projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionLogsOf(join(projects, name), now))
|
|
104
|
+
|
|
66
105
|
/** The names the gate holds an open request under, as the watcher reads them. */
|
|
67
106
|
const heldNames = (root: string, now: number): Set<string> =>
|
|
68
107
|
new Set(
|
|
@@ -71,12 +110,22 @@ const heldNames = (root: string, now: number): Set<string> =>
|
|
|
71
110
|
.map((request) => request.lane),
|
|
72
111
|
)
|
|
73
112
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
|
|
115
|
+
* stale left out. The one chain the herd (`readSubagentActivity`) and the watcher's heartbeat share.
|
|
116
|
+
*/
|
|
117
|
+
export const readRunningSubagents = (
|
|
118
|
+
{ projects, root, approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
|
|
77
119
|
now: number = Date.now(),
|
|
78
|
-
):
|
|
79
|
-
|
|
80
|
-
|
|
120
|
+
): RunningSubagent[] =>
|
|
121
|
+
runningSubagents(
|
|
122
|
+
readSubagentLogs(projects, root, now, worktreeDirectory),
|
|
123
|
+
heldNames(approvalsRoot, now),
|
|
124
|
+
now,
|
|
81
125
|
worktreeDirectory,
|
|
126
|
+
skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
|
|
82
127
|
)
|
|
128
|
+
|
|
129
|
+
/** The state of every worktree a subagent of the main checkout's sessions works in. */
|
|
130
|
+
export const readSubagentActivity = (source: SubagentSource, now: number = Date.now()): Map<string, SubagentActivity> =>
|
|
131
|
+
subagentsByLane(readRunningSubagents(source, now), source.worktreeDirectory)
|
package/src/plans/subagents.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { terminalSafe } from '../plan-format/terminal-text'
|
|
|
4
4
|
import { isRecord } from '../shared/is-record'
|
|
5
5
|
import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
6
6
|
import { activityTextOf } from './current-task'
|
|
7
|
+
import { type PhaselessReview, type SubagentPhaseRun, subagentPhaseRunOf } from './subagent-phases'
|
|
8
|
+
import type { DescribedSubagent } from './subagent-tasks'
|
|
7
9
|
import { laneDirectoryOf } from './unlisted-lanes'
|
|
8
10
|
|
|
9
11
|
/** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
|
|
@@ -17,10 +19,25 @@ export type RunningSubagent = {
|
|
|
17
19
|
status: Extract<AgentStatus, 'working' | 'blocked'>
|
|
18
20
|
/** The text of its last log entries, from which the herd tells the task it works on (`taskSignalsOf`). */
|
|
19
21
|
activity: string
|
|
22
|
+
/** The phases its full description names (`P21`, `P17-P20`), none for a review that names none; null for any other. */
|
|
23
|
+
phaseRun: SubagentPhaseRun | null
|
|
24
|
+
/** When its log was last written. */
|
|
25
|
+
writtenAt: number
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
/** What a worktree shows of the subagents working in it. */
|
|
23
|
-
export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'>
|
|
28
|
+
/** What a worktree shows of the subagents working in it; `phaseRuns` are those of all its subagents that name a phase. */
|
|
29
|
+
export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'> & {
|
|
30
|
+
phaseRuns: SubagentPhaseRun[]
|
|
31
|
+
/** Its reviews that name no phase; the lane places them on its signalled phase (`laneSummary`). */
|
|
32
|
+
phaselessReviews: PhaselessReview[]
|
|
33
|
+
/**
|
|
34
|
+
* The descriptions of its other subagents that name no phase: what they work on, a signal of the lane like a
|
|
35
|
+
* reported task.
|
|
36
|
+
*/
|
|
37
|
+
taskDescriptions: string[]
|
|
38
|
+
/** Its subagents that are no review, phase or not; one whose description starts with a task's short name sits on it. */
|
|
39
|
+
describedSubagents: DescribedSubagent[]
|
|
40
|
+
}
|
|
24
41
|
|
|
25
42
|
/**
|
|
26
43
|
* A log untouched this long is not at work: Claude Code marks a finished subagent with `finished: true` in its
|
|
@@ -85,18 +102,21 @@ const workingDirectory = (tail: readonly string[], worktreeDirectory: string): s
|
|
|
85
102
|
/**
|
|
86
103
|
* The subagents at work: no `finished` mark in the meta, and the log written within `SUBAGENT_STALE_MINUTES`. One
|
|
87
104
|
* waits for an answer while the approval gate holds a request under its name (`openRequests` are the names of the open
|
|
88
|
-
* requests); the transcript alone cannot tell a permission dialog from a long tool call.
|
|
105
|
+
* requests); the transcript alone cannot tell a permission dialog from a long tool call. `reviewPrefixes` are the skill
|
|
106
|
+
* prefixes a copy of the review agents may carry.
|
|
89
107
|
*/
|
|
90
108
|
export const runningSubagents = (
|
|
91
109
|
logs: readonly SubagentLog[],
|
|
92
110
|
openRequests: ReadonlySet<string>,
|
|
93
111
|
now: number,
|
|
94
112
|
worktreeDirectory: string,
|
|
113
|
+
reviewPrefixes: readonly string[],
|
|
95
114
|
): RunningSubagent[] =>
|
|
96
115
|
logs.flatMap(({ id, meta, modifiedAt, tail }) => {
|
|
97
116
|
if (!isRecord(meta) || meta.finished === true || now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE)
|
|
98
117
|
return []
|
|
99
|
-
const
|
|
118
|
+
const agentType = textOrNull(meta.agentType)
|
|
119
|
+
const label = subagentLabelOf(id, agentType)
|
|
100
120
|
if (label === null) return []
|
|
101
121
|
const description = textOrNull(typeof meta.description === 'string' ? terminalSafe(meta.description) : null)
|
|
102
122
|
const shortDescription = description?.slice(0, DESCRIPTION_LENGTH) ?? null
|
|
@@ -107,25 +127,63 @@ export const runningSubagents = (
|
|
|
107
127
|
description: shortDescription,
|
|
108
128
|
status: openRequests.has(label) ? AGENT_STATUS.blocked : AGENT_STATUS.working,
|
|
109
129
|
activity: activityTextOf(tail, ACTIVITY_ENTRIES),
|
|
130
|
+
phaseRun: subagentPhaseRunOf(description, agentType, modifiedAt, reviewPrefixes),
|
|
131
|
+
writtenAt: modifiedAt,
|
|
110
132
|
},
|
|
111
133
|
]
|
|
112
134
|
})
|
|
113
135
|
|
|
136
|
+
/** The description of a subagent that names no phase, as a list of at most one; empty for a review or one that names a phase. */
|
|
137
|
+
const taskDescriptionOf = ({ phaseRun, description }: RunningSubagent): string[] =>
|
|
138
|
+
phaseRun === null && description !== null ? [description] : []
|
|
139
|
+
|
|
140
|
+
/** A subagent that is no review, with its description, as a list of at most one. */
|
|
141
|
+
const describedSubagentOf = ({ phaseRun, description, writtenAt }: RunningSubagent): DescribedSubagent[] =>
|
|
142
|
+
description !== null && (phaseRun === null || phaseRun.review === null) ? [{ description, at: writtenAt }] : []
|
|
143
|
+
|
|
144
|
+
/** The run of a subagent that names a phase, as a list of at most one. */
|
|
145
|
+
const namedRunOf = ({ phaseRun }: RunningSubagent): SubagentPhaseRun[] =>
|
|
146
|
+
phaseRun !== null && phaseRun.ids.length > 0 ? [phaseRun] : []
|
|
147
|
+
|
|
148
|
+
/** A review that names no phase, as a list of at most one. */
|
|
149
|
+
const phaselessReviewOf = ({ phaseRun, description }: RunningSubagent): PhaselessReview[] => {
|
|
150
|
+
if (phaseRun === null || phaseRun.review === null || phaseRun.ids.length > 0) return []
|
|
151
|
+
return [{ review: phaseRun.review, at: phaseRun.at, description }]
|
|
152
|
+
}
|
|
153
|
+
|
|
114
154
|
/** The worktrees below `worktreeDirectory` the subagents work in; one that waits for an answer decides the state. */
|
|
115
155
|
export const subagentsByLane = (
|
|
116
156
|
running: readonly RunningSubagent[],
|
|
117
157
|
worktreeDirectory: string,
|
|
118
158
|
): Map<string, SubagentActivity> => {
|
|
119
159
|
const lanes = new Map<string, SubagentActivity>()
|
|
120
|
-
for (const
|
|
160
|
+
for (const subagent of running) {
|
|
161
|
+
const { cwd, status, description, activity } = subagent
|
|
121
162
|
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
122
163
|
if (lane === null) continue
|
|
123
164
|
const known = lanes.get(lane)
|
|
124
|
-
|
|
165
|
+
const runs = namedRunOf(subagent)
|
|
166
|
+
const reviews = phaselessReviewOf(subagent)
|
|
167
|
+
const tasks = taskDescriptionOf(subagent)
|
|
168
|
+
const described = describedSubagentOf(subagent)
|
|
169
|
+
if (!known)
|
|
170
|
+
lanes.set(lane, {
|
|
171
|
+
status,
|
|
172
|
+
description,
|
|
173
|
+
activity,
|
|
174
|
+
phaseRuns: runs,
|
|
175
|
+
phaselessReviews: reviews,
|
|
176
|
+
taskDescriptions: tasks,
|
|
177
|
+
describedSubagents: described,
|
|
178
|
+
})
|
|
125
179
|
else
|
|
126
180
|
lanes.set(lane, {
|
|
127
181
|
...(status === AGENT_STATUS.blocked && known.status !== AGENT_STATUS.blocked ? { status, description } : known),
|
|
128
182
|
activity: `${known.activity} ${activity}`,
|
|
183
|
+
phaseRuns: [...known.phaseRuns, ...runs],
|
|
184
|
+
phaselessReviews: [...known.phaselessReviews, ...reviews],
|
|
185
|
+
taskDescriptions: [...known.taskDescriptions, ...tasks],
|
|
186
|
+
describedSubagents: [...known.describedSubagents, ...described],
|
|
129
187
|
})
|
|
130
188
|
}
|
|
131
189
|
return lanes
|
package/src/plans/supervisor.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
|
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
|
|
6
6
|
import type { StartedKit } from '../version/started-kit'
|
|
7
|
+
import { STOP_REQUEST, type Stopper, stopperOf } from './child-stop'
|
|
7
8
|
import { HERD_COMMAND } from './herd-command'
|
|
8
9
|
import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
|
|
9
10
|
import { TERMINAL_OFF } from './view'
|
|
@@ -30,7 +31,8 @@ const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
|
|
|
30
31
|
/**
|
|
31
32
|
* Runs the live view as a child process and restarts it when the package's own source changes,
|
|
32
33
|
* so a long-lived `plans` never shows the behaviour of code that has since been edited. The child
|
|
33
|
-
* stops on SIGTERM through the handler that restores the terminal
|
|
34
|
+
* stops on SIGTERM through the handler that restores the terminal, and is killed when it does not end within
|
|
35
|
+
* `GRACE_MS` (a restart included); a second signal while stopping kills it at once. Resolves with the exit code
|
|
34
36
|
* of a wanted end only: the child's clean exit (`q`) or a signal forwarded to it. A child that
|
|
35
37
|
* dies otherwise leaves the supervisor waiting, with the terminal restored, for the next change
|
|
36
38
|
* to `src/`, which spawns a fresh child. `kit` is the package this process loaded (null when it cannot be
|
|
@@ -45,6 +47,7 @@ export const superviseView = (
|
|
|
45
47
|
): Promise<number> =>
|
|
46
48
|
new Promise((resolveExit) => {
|
|
47
49
|
let child: ChildProcess | undefined
|
|
50
|
+
let stopper: Stopper | undefined
|
|
48
51
|
let restarting = false
|
|
49
52
|
let stopping = false
|
|
50
53
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -66,6 +69,12 @@ export const superviseView = (
|
|
|
66
69
|
const start = (isFirst: boolean): void => {
|
|
67
70
|
const current = spawnView(scriptPath, args)
|
|
68
71
|
child = current
|
|
72
|
+
const currentStopper = stopperOf({
|
|
73
|
+
kill: (signal) => current.kill(signal),
|
|
74
|
+
setTimer: (run, ms) => setTimeout(run, ms),
|
|
75
|
+
clearTimer: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
76
|
+
})
|
|
77
|
+
stopper = currentStopper
|
|
69
78
|
|
|
70
79
|
current.on('error', (error) => {
|
|
71
80
|
if (child !== current) return
|
|
@@ -79,6 +88,7 @@ export const superviseView = (
|
|
|
79
88
|
})
|
|
80
89
|
|
|
81
90
|
current.on('exit', (code, signal) => {
|
|
91
|
+
currentStopper.ended()
|
|
82
92
|
if (child !== current) return
|
|
83
93
|
const context = { restarting, stopping, code, signal }
|
|
84
94
|
restarting = false
|
|
@@ -94,7 +104,7 @@ export const superviseView = (
|
|
|
94
104
|
const restartView = (): void => {
|
|
95
105
|
if (child) {
|
|
96
106
|
restarting = true
|
|
97
|
-
|
|
107
|
+
stopper?.request('SIGTERM')
|
|
98
108
|
} else start(false)
|
|
99
109
|
}
|
|
100
110
|
|
|
@@ -118,8 +128,8 @@ export const superviseView = (
|
|
|
118
128
|
stopping = true
|
|
119
129
|
clearTimeout(timer)
|
|
120
130
|
clearInterval(versionTimer)
|
|
121
|
-
if (child)
|
|
122
|
-
else finish(exitCodeOf(null, signal))
|
|
131
|
+
if (!child || !stopper) finish(exitCodeOf(null, signal))
|
|
132
|
+
else if (stopper.request(signal) === STOP_REQUEST.killed) finish(exitCodeOf(null, signal))
|
|
123
133
|
})
|
|
124
134
|
}
|
|
125
135
|
})
|
package/src/plans/symbols.ts
CHANGED
|
@@ -21,6 +21,9 @@ export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WO
|
|
|
21
21
|
/** Before a row that opens and closes: lanes, the planned phases, the ticked items of a phase. */
|
|
22
22
|
export const CHEVRON = { closed: '▸', open: '▾' } as const
|
|
23
23
|
|
|
24
|
+
/** Before the count under a block that shows all its rows; a click folds it back to five. */
|
|
25
|
+
export const LESS_MARK = '▴'
|
|
26
|
+
|
|
24
27
|
export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
|
|
25
28
|
|
|
26
29
|
export const PHASE_STATE = { missing: 'missing', empty: 'empty', all: 'all', open: 'open' } as const
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const TASK_NAME = /^\*\*([^*]+)\*\*:/
|
|
2
|
+
|
|
3
|
+
const TITLE_END = ': '
|
|
4
|
+
|
|
5
|
+
const titleEndOf = (text: string): number => {
|
|
6
|
+
let inCode = false
|
|
7
|
+
for (let index = 0; index < text.length; index++) {
|
|
8
|
+
if (text[index] === '`') inCode = !inCode
|
|
9
|
+
else if (!inCode && text.startsWith(TITLE_END, index)) return index
|
|
10
|
+
}
|
|
11
|
+
return -1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The text up to its first colon followed by a space outside backticks, as a folded row shows it; the text as it is
|
|
16
|
+
* without such a colon or with nothing before it. A time like `18:55` has no space after its colon and does not split.
|
|
17
|
+
*/
|
|
18
|
+
export const shortTitle = (text: string): string => {
|
|
19
|
+
const end = titleEndOf(text)
|
|
20
|
+
return end > 0 ? text.slice(0, end) : text
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The short name a task carries in bold at its start, `**Name**: content`, as written; null for a line without one.
|
|
25
|
+
* Nothing is guessed from the text up to its first colon: a path at the start of a line would read as a name.
|
|
26
|
+
*/
|
|
27
|
+
export const taskNameOf = (text: string): string | null => {
|
|
28
|
+
const name = text.match(TASK_NAME)?.[1]?.trim()
|
|
29
|
+
return name ? name : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The text after a task's `**Name**:`, as an opened task shows it under its name; the text as it is without a name. */
|
|
33
|
+
export const taskBodyOf = (text: string): string =>
|
|
34
|
+
taskNameOf(text) === null ? text : text.replace(TASK_NAME, '').trimStart()
|
package/src/plans/texts.ts
CHANGED
|
@@ -3,11 +3,11 @@ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from '.
|
|
|
3
3
|
import { shownWithMore } from './shown-with-more'
|
|
4
4
|
import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
|
|
5
5
|
|
|
6
|
-
/** The words of an open lane: the age of a running phase, the
|
|
6
|
+
/** The words of an open lane: the age of a running phase, the words before the open phases that wait and the finished ones, and the pointer of a phase with only its review gate open. */
|
|
7
7
|
export type HerdTexts = {
|
|
8
8
|
minutesAgo: (minutes: number) => string
|
|
9
9
|
next: string
|
|
10
|
-
|
|
10
|
+
done: string
|
|
11
11
|
reviewGate: string
|
|
12
12
|
gate: string
|
|
13
13
|
}
|
|
@@ -23,9 +23,14 @@ export type PlanTexts = {
|
|
|
23
23
|
noPlan: string
|
|
24
24
|
noWorktree: string
|
|
25
25
|
noRow: string
|
|
26
|
+
/** The heads of the blocks on top of the herd. */
|
|
27
|
+
waitingHeading: string
|
|
28
|
+
appointmentHeading: string
|
|
26
29
|
notInPlan: string
|
|
27
30
|
/** Before a task an agent reports that no task of the plan names. */
|
|
28
31
|
now: string
|
|
32
|
+
/** Before the description of a running subagent that names no phase and sits on none. */
|
|
33
|
+
subagent: string
|
|
29
34
|
/** In place of the count of a phase whose only open box is the documentation box of its review gate. */
|
|
30
35
|
allButDocs: string
|
|
31
36
|
tickedSummary: (count: number) => string
|
|
@@ -36,7 +41,13 @@ export type PlanTexts = {
|
|
|
36
41
|
herd: HerdTexts
|
|
37
42
|
checks: Record<ChecksState, string>
|
|
38
43
|
hint: string
|
|
44
|
+
/** The notice when `a` is pressed with no phase open, where the key changes nothing on screen. */
|
|
45
|
+
showDoneNeedsPhase: string
|
|
39
46
|
legend: string[]
|
|
47
|
+
/** The footer line when the coordinator could not be asked to put a question, with the reason. */
|
|
48
|
+
notAsked: (reason: string) => string
|
|
49
|
+
/** The footer line when the coordinator `name` was asked to put `subject`, `question <id>` or `board row <key>`. */
|
|
50
|
+
asked: (name: string, subject: string) => string
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
/** The words of the watcher's badges and of the header notice when they are missing. */
|
|
@@ -78,8 +89,11 @@ export const TEXTS: PlanTexts = {
|
|
|
78
89
|
noPlan: 'no plan',
|
|
79
90
|
noWorktree: 'no worktree',
|
|
80
91
|
noRow: 'no row in lanes',
|
|
92
|
+
waitingHeading: 'Waiting for you',
|
|
93
|
+
appointmentHeading: 'Appointments',
|
|
81
94
|
notInPlan: 'not in the plan',
|
|
82
95
|
now: 'now:',
|
|
96
|
+
subagent: 'Subagent:',
|
|
83
97
|
allButDocs: ALL_BUT_DOCS,
|
|
84
98
|
tickedSummary: (count) => `${count} ticked · click or a lists them`,
|
|
85
99
|
draft: 'draft · ',
|
|
@@ -88,7 +102,7 @@ export const TEXTS: PlanTexts = {
|
|
|
88
102
|
herd: {
|
|
89
103
|
minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
|
|
90
104
|
next: 'next',
|
|
91
|
-
|
|
105
|
+
done: 'done',
|
|
92
106
|
reviewGate: 'Review gate',
|
|
93
107
|
gate: '· gate',
|
|
94
108
|
},
|
|
@@ -100,16 +114,19 @@ export const TEXTS: PlanTexts = {
|
|
|
100
114
|
[CHECKS.pending]: '… checks',
|
|
101
115
|
[CHECKS.none]: 'no checks',
|
|
102
116
|
},
|
|
103
|
-
hint: '↑↓ select →← open a
|
|
117
|
+
hint: '↑↓ select →← open a ticked tasks s html q quit ? help',
|
|
118
|
+
showDoneNeedsPhase: 'a lists the ticked tasks inside an open phase: open one with → or a click',
|
|
104
119
|
legend: [
|
|
105
120
|
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
|
|
106
121
|
'● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
|
|
107
|
-
`▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next,
|
|
122
|
+
`▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, done · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · a running review sits on the gate box that names it, in the phase it names or else the lane's current one · Subagent: <text> = a running subagent that names no phase and sits on none · next = the other open phases, as ranges · done = the finished ones · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes of an open phase one by one · s opens every lane and plan as one HTML page`,
|
|
108
123
|
`the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
|
|
109
124
|
'↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
|
|
110
|
-
'▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board · it goes when the board row is closed or `jh waiting close <id>` ·
|
|
125
|
+
'▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board or one a session left in its dialog (a click on its text only expands and folds it; [ask] at the right edge of a question asks the coordinator to put it) · it goes when the board row is closed or `jh waiting close <id>` · `Waiting for you` heads the block (click: fold; click on +n: show all, on ▴ n: five) · ◷ the appointments of the board in a grey bar below it (click: fold)',
|
|
111
126
|
'⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
|
|
112
127
|
],
|
|
128
|
+
notAsked: (reason) => `no coordinator to ask: ${reason}`,
|
|
129
|
+
asked: (name, subject) => `asked ${name} for ${subject}`,
|
|
113
130
|
}
|
|
114
131
|
|
|
115
132
|
/** The mark of a running lane the board has no row for; null for a lane it lists. */
|
package/src/plans/view.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
|
|
1
2
|
import { fit, plainText } from '../terminal/text'
|
|
2
3
|
import { renderSelected, type Screen } from './layout'
|
|
3
4
|
import type { Options } from './options'
|
|
@@ -20,7 +21,9 @@ const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
|
|
|
20
21
|
export const bodyRows = (screen: Screen): number =>
|
|
21
22
|
Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/** The time as `HH:MM`, in the header and in the refresh error alike: a frame differs only once a minute. */
|
|
25
|
+
export const clockOf = (locale: string, now: Date = new Date()): string =>
|
|
26
|
+
now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
|
24
27
|
|
|
25
28
|
export const renderSnapshot = (
|
|
26
29
|
snapshot: Snapshot,
|
|
@@ -38,6 +41,7 @@ export const renderSnapshot = (
|
|
|
38
41
|
watch: snapshot.watch,
|
|
39
42
|
watchesTickets: snapshot.watchesTickets,
|
|
40
43
|
awaiting: snapshot.waiting,
|
|
44
|
+
appointments: snapshot.appointments,
|
|
41
45
|
unplaced: snapshot.unplaced,
|
|
42
46
|
},
|
|
43
47
|
selection,
|
|
@@ -50,19 +54,23 @@ export const startSelection = (snapshot: Snapshot, options: Options): Selection
|
|
|
50
54
|
options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
|
|
51
55
|
|
|
52
56
|
/** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
|
|
53
|
-
export const
|
|
54
|
-
const width = paneWidth()
|
|
55
|
-
const rows = bodyRows(screen)
|
|
57
|
+
export const frameOutput = (screen: Screen, top: number, width: number, rows: number): string => {
|
|
56
58
|
const body = screen.body.lines.slice(top, top + rows)
|
|
57
59
|
const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
|
|
58
60
|
const lines = [...screen.header, ...body, ...padding, ...screen.footer]
|
|
59
|
-
|
|
60
|
-
`${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`,
|
|
61
|
-
)
|
|
61
|
+
return `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/** The frame to write, or null when the terminal already shows it; a null `previous` means the terminal content is unknown. */
|
|
65
|
+
export const frameToWrite = (previous: string | null, next: string): string | null => (previous === next ? null : next)
|
|
66
|
+
|
|
67
|
+
const ARTIFACT_ADDRESS = new RegExp(ARTIFACT_LINK_KIND.pattern)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One frame as text; ANSI styling is dropped when stdout is not a terminal, and links are written out as their
|
|
71
|
+
* address, except an Artifact's, which is long and would push the rest of its line past the pane.
|
|
72
|
+
*/
|
|
65
73
|
export const frameText = (screen: Screen, isTerminal: boolean): string => {
|
|
66
74
|
const lines = [...screen.header, ...screen.body.lines, ...screen.footer]
|
|
67
|
-
return (isTerminal ? lines : lines.map(plainText)).join('\n')
|
|
75
|
+
return (isTerminal ? lines : lines.map((line) => plainText(line, (url) => !ARTIFACT_ADDRESS.test(url)))).join('\n')
|
|
68
76
|
}
|