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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isPhaseOpen, type PlanPhase } from '../plan-format'
|
|
2
|
+
import { PHASE_ID_PREFIX, spannedPhaseIds } from '../plan-format/lanes'
|
|
3
|
+
import { isNamedBy } from './phase-ids'
|
|
4
|
+
|
|
5
|
+
/** The agent types that review a phase; they sit on its review gate. */
|
|
6
|
+
export const REVIEW_TYPE = {
|
|
7
|
+
uncleBob: 'uncle-bob',
|
|
8
|
+
specReview: 'spec-review',
|
|
9
|
+
securityAudit: 'security-audit',
|
|
10
|
+
review: 'review',
|
|
11
|
+
ive: 'ive',
|
|
12
|
+
jobs: 'jobs',
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export type ReviewType = (typeof REVIEW_TYPE)[keyof typeof REVIEW_TYPE]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The phases a running subagent names in its description, its review type (null for no review), and when its log was
|
|
19
|
+
* last written. `ids` is empty only for a review that names no phase: the lane places it (`PhaselessReview`).
|
|
20
|
+
*/
|
|
21
|
+
export type SubagentPhaseRun = { ids: string[]; review: ReviewType | null; at: number }
|
|
22
|
+
|
|
23
|
+
/** A running review whose description names no phase: it sits on the lane's signalled phase, else shows as its description. */
|
|
24
|
+
export type PhaselessReview = { review: ReviewType; at: number; description: string | null }
|
|
25
|
+
|
|
26
|
+
/** What the subagents say about one phase of the lane: the types of the reviews among them, and the newest log write. */
|
|
27
|
+
export type SubagentPhase = { reviews: ReviewType[]; at: number }
|
|
28
|
+
|
|
29
|
+
const isReviewTypeName = (type: string): type is ReviewType => (Object.values(REVIEW_TYPE) as string[]).includes(type)
|
|
30
|
+
|
|
31
|
+
/** `P21`, `P17-P20`, `P17 – 19`, `Phase 21`, `Phase 10c`: the first number, an optional sub-phase letter, an optional end. */
|
|
32
|
+
const PHASE_MENTION = new RegExp(
|
|
33
|
+
`(?:\\b${PHASE_ID_PREFIX}|\\bPhase\\s+)(\\d+)([a-z]?)(?:\\s*[-–—]\\s*(?:${PHASE_ID_PREFIX})?(\\d+)\\b)?`,
|
|
34
|
+
'gi',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** The ids of a range `first`-`last`; a range that runs backwards or is absurdly wide names only its first end. */
|
|
38
|
+
const rangeIds = (first: number, last: number): string[] => {
|
|
39
|
+
const spanned = spannedPhaseIds(first, last)
|
|
40
|
+
return spanned.length > 0 ? spanned : [String(first)]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The ids of the phases a description names, in the order named; a range names every phase between its ends. */
|
|
44
|
+
export const phaseIdsOfDescription = (description: string): string[] =>
|
|
45
|
+
[...description.matchAll(PHASE_MENTION)].flatMap(([, first = '', letter = '', last]) =>
|
|
46
|
+
last === undefined ? [`${Number(first)}${letter.toLowerCase()}`] : rangeIds(Number(first), Number(last)),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const withoutPrefix = (type: string, prefixes: readonly string[]): string => {
|
|
50
|
+
const prefix = prefixes.find((each) => each !== '' && type.startsWith(each))
|
|
51
|
+
return prefix === undefined ? type : type.slice(prefix.length)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `REVIEW_TYPE` an agent type is, plain or with one of the skill `prefixes` of a copy; null for any other type. */
|
|
55
|
+
export const reviewTypeOf = (type: string | null, prefixes: readonly string[]): ReviewType | null => {
|
|
56
|
+
const plain = type === null ? null : withoutPrefix(type, prefixes)
|
|
57
|
+
return plain !== null && isReviewTypeName(plain) ? plain : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The run of a subagent from its `description` and `agentType`: null when the description names no phase and the
|
|
62
|
+
* subagent is no review. The description is read whole, not cut for the display.
|
|
63
|
+
*/
|
|
64
|
+
export const subagentPhaseRunOf = (
|
|
65
|
+
description: string | null,
|
|
66
|
+
agentType: string | null,
|
|
67
|
+
at: number,
|
|
68
|
+
reviewPrefixes: readonly string[],
|
|
69
|
+
): SubagentPhaseRun | null => {
|
|
70
|
+
const ids = description === null ? [] : phaseIdsOfDescription(description)
|
|
71
|
+
const review = reviewTypeOf(agentType, reviewPrefixes)
|
|
72
|
+
return ids.length === 0 && review === null ? null : { ids, review, at }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const names = (run: SubagentPhaseRun, id: string): boolean => isNamedBy(id, new Set(run.ids))
|
|
76
|
+
|
|
77
|
+
const reviewsOf = (runs: readonly SubagentPhaseRun[]): ReviewType[] => [
|
|
78
|
+
...new Set(runs.flatMap(({ review }) => (review === null ? [] : [review]))),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The open phases of the lane that running subagents name, by phase id: a sub-phase (`21b`) is named by its main phase.
|
|
83
|
+
* Phases the lane does not hold are left out, since another lane's phases are not its own.
|
|
84
|
+
*/
|
|
85
|
+
export const subagentPhases = (
|
|
86
|
+
phases: readonly Pick<PlanPhase, 'id' | 'items'>[],
|
|
87
|
+
runs: readonly SubagentPhaseRun[],
|
|
88
|
+
): Map<string, SubagentPhase> =>
|
|
89
|
+
new Map(
|
|
90
|
+
phases.filter(isPhaseOpen).flatMap(({ id }) => {
|
|
91
|
+
const naming = runs.filter((run) => names(run, id))
|
|
92
|
+
if (naming.length === 0) return []
|
|
93
|
+
return [[id, { reviews: reviewsOf(naming), at: Math.max(...naming.map(({ at }) => at)) }]]
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ActiveTask } from './active-tasks'
|
|
2
|
+
import type { TaskPhase } from './current-task'
|
|
3
|
+
import { taskNameOf } from './task-name'
|
|
4
|
+
|
|
5
|
+
/** A running subagent that is no review: its description as the herd shows it, and when its log was last written. */
|
|
6
|
+
export type DescribedSubagent = { description: string; at: number }
|
|
7
|
+
|
|
8
|
+
/** Where the subagents sit: their tasks by phase id, and the descriptions placed, which show no line of their own. */
|
|
9
|
+
export type SubagentTaskPlacements = { byPhase: Map<string, ActiveTask[]>; placed: string[] }
|
|
10
|
+
|
|
11
|
+
type NamedTask = { phaseId: string; index: number; name: string }
|
|
12
|
+
|
|
13
|
+
/** A letter or digit right after the name continues a word, so `Probe` does not name `Proben lesen`. */
|
|
14
|
+
const WORD_GOES_ON = /^[\p{L}\p{N}]/u
|
|
15
|
+
|
|
16
|
+
/** The open tasks that carry a short name, the longest names first, so a name that starts another loses to it. */
|
|
17
|
+
const namedOpenTasks = (phases: readonly TaskPhase[]): NamedTask[] =>
|
|
18
|
+
phases
|
|
19
|
+
.flatMap(({ id, items }) =>
|
|
20
|
+
items.flatMap((item, index) => {
|
|
21
|
+
const name = item.done || item.gate ? null : taskNameOf(item.text)
|
|
22
|
+
return name === null ? [] : [{ phaseId: id, index, name }]
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
.sort((first, second) => second.name.length - first.name.length)
|
|
26
|
+
|
|
27
|
+
const startsWithName = (description: string, name: string): boolean =>
|
|
28
|
+
description.toLowerCase().startsWith(name.toLowerCase()) && !WORD_GOES_ON.test(description.slice(name.length))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The open task each subagent works on, from the short name its description starts with (`/implement` Step 0b, case
|
|
32
|
+
* ignored): it gets its `▶` like a reported task, one per task however many subagents share it. A ticked task and a
|
|
33
|
+
* review gate item take none; a subagent whose description starts with no name stays where it was.
|
|
34
|
+
*/
|
|
35
|
+
export const subagentTaskPlacements = (
|
|
36
|
+
phases: readonly TaskPhase[],
|
|
37
|
+
subagents: readonly DescribedSubagent[],
|
|
38
|
+
): SubagentTaskPlacements => {
|
|
39
|
+
const tasks = namedOpenTasks(phases)
|
|
40
|
+
const byPhase = new Map<string, ActiveTask[]>()
|
|
41
|
+
const placed: string[] = []
|
|
42
|
+
for (const { description, at } of subagents) {
|
|
43
|
+
const task = tasks.find(({ name }) => startsWithName(description, name))
|
|
44
|
+
if (task === undefined) continue
|
|
45
|
+
placed.push(description)
|
|
46
|
+
const known = byPhase.get(task.phaseId) ?? []
|
|
47
|
+
if (known.some(({ index }) => index === task.index)) continue
|
|
48
|
+
byPhase.set(task.phaseId, [...known, { index: task.index, agent: null, guessed: false, startedAt: at }])
|
|
49
|
+
}
|
|
50
|
+
return { byPhase, placed }
|
|
51
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defaultProjectsDirectory, projectDirectoryOf, readTailLines } from '../context/session-log'
|
|
4
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
5
|
+
import { DEFAULT_SKILL_PREFIX } from '../sync/init'
|
|
6
|
+
import { readApprovalRequests } from '../watch/approvals'
|
|
7
|
+
import {
|
|
8
|
+
type RunningSubagent,
|
|
9
|
+
runningSubagents,
|
|
10
|
+
SUBAGENT_STALE_MINUTES,
|
|
11
|
+
type SubagentActivity,
|
|
12
|
+
type SubagentLog,
|
|
13
|
+
subagentsByLane,
|
|
14
|
+
} from './subagents'
|
|
15
|
+
|
|
16
|
+
export type SubagentSource = {
|
|
17
|
+
projects: string
|
|
18
|
+
/** The main checkout whose sessions are read. */
|
|
19
|
+
root: string
|
|
20
|
+
/** The main checkout whose `.jackherd/approvals/` holds the gate's requests. */
|
|
21
|
+
approvalsRoot: string
|
|
22
|
+
/** The absolute worktree directory. */
|
|
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
|
|
26
|
+
}
|
|
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
|
+
|
|
37
|
+
/** The end of a log that holds the directory of the last entries. */
|
|
38
|
+
const TAIL_BYTES = 64 * 1024
|
|
39
|
+
const META_FILE = /^agent-([0-9a-f]+)\.meta\.json$/
|
|
40
|
+
/** A request the gate holds is reported for this long, as by the watcher. */
|
|
41
|
+
const OPEN_REQUEST_MINUTES = 30
|
|
42
|
+
|
|
43
|
+
const directoriesOf = (path: string): string[] => {
|
|
44
|
+
try {
|
|
45
|
+
return readdirSync(path, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? [entry.name] : []))
|
|
46
|
+
} catch {
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
}
|
|
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
|
+
|
|
60
|
+
const logOf = (directory: string, name: string, id: string, now: number): SubagentLog[] => {
|
|
61
|
+
try {
|
|
62
|
+
const logPath = join(directory, `agent-${id}.jsonl`)
|
|
63
|
+
const modifiedAt = statSync(logPath).mtimeMs
|
|
64
|
+
if (now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE) return []
|
|
65
|
+
const meta: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
|
|
66
|
+
return [{ id, meta, modifiedAt, tail: readTailLines(logPath, TAIL_BYTES) }]
|
|
67
|
+
} catch {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The subagent logs of every session in one project directory. */
|
|
73
|
+
const sessionLogsOf = (project: string, now: number): SubagentLog[] =>
|
|
74
|
+
directoriesOf(project).flatMap((session) => {
|
|
75
|
+
const directory = join(project, session, 'subagents')
|
|
76
|
+
return namesIn(directory).flatMap((name) => {
|
|
77
|
+
const id = META_FILE.exec(name)?.[1]
|
|
78
|
+
return id ? logOf(directory, name, id, now) : []
|
|
79
|
+
})
|
|
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)))
|
|
90
|
+
}
|
|
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
|
+
|
|
105
|
+
/** The names the gate holds an open request under, as the watcher reads them. */
|
|
106
|
+
const heldNames = (root: string, now: number): Set<string> =>
|
|
107
|
+
new Set(
|
|
108
|
+
readApprovalRequests(root)
|
|
109
|
+
.filter((request) => request.usedAt === null && now - request.createdAt <= OPEN_REQUEST_MINUTES * MS_PER_MINUTE)
|
|
110
|
+
.map((request) => request.lane),
|
|
111
|
+
)
|
|
112
|
+
|
|
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,
|
|
119
|
+
now: number = Date.now(),
|
|
120
|
+
): RunningSubagent[] =>
|
|
121
|
+
runningSubagents(
|
|
122
|
+
readSubagentLogs(projects, root, now, worktreeDirectory),
|
|
123
|
+
heldNames(approvalsRoot, now),
|
|
124
|
+
now,
|
|
125
|
+
worktreeDirectory,
|
|
126
|
+
skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
|
|
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)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { parseLogEntry } from '../context/session-log'
|
|
2
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
6
|
+
import { activityTextOf } from './current-task'
|
|
7
|
+
import { type PhaselessReview, type SubagentPhaseRun, subagentPhaseRunOf } from './subagent-phases'
|
|
8
|
+
import type { DescribedSubagent } from './subagent-tasks'
|
|
9
|
+
import { laneDirectoryOf } from './unlisted-lanes'
|
|
10
|
+
|
|
11
|
+
/** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
|
|
12
|
+
export type SubagentLog = { id: string; meta: unknown; modifiedAt: number; tail: readonly string[] }
|
|
13
|
+
|
|
14
|
+
/** A subagent of the coordinator that is at work now. */
|
|
15
|
+
export type RunningSubagent = {
|
|
16
|
+
label: string
|
|
17
|
+
cwd: string | null
|
|
18
|
+
description: string | null
|
|
19
|
+
status: Extract<AgentStatus, 'working' | 'blocked'>
|
|
20
|
+
/** The text of its last log entries, from which the herd tells the task it works on (`taskSignalsOf`). */
|
|
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
|
|
26
|
+
}
|
|
27
|
+
|
|
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
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A log untouched this long is not at work: Claude Code marks a finished subagent with `finished: true` in its
|
|
44
|
+
* `meta.json` only in its newer versions, so a subagent that was killed or ended before that has no mark.
|
|
45
|
+
*/
|
|
46
|
+
export const SUBAGENT_STALE_MINUTES = 15
|
|
47
|
+
const AGENT_ID = /^[0-9a-f]{8,64}$/
|
|
48
|
+
const AGENT_TYPE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
49
|
+
const ID_LENGTH = 8
|
|
50
|
+
const ACTIVITY_ENTRIES = 40
|
|
51
|
+
const DESCRIPTION_LENGTH = 80
|
|
52
|
+
|
|
53
|
+
/** The name the approval gate holds a subagent under (`kit/hooks/hooks/approval-gate/subagent.ts`, contract test). */
|
|
54
|
+
export const subagentLabelOf = (id: string, type: string | null): string | null =>
|
|
55
|
+
AGENT_ID.test(id)
|
|
56
|
+
? `subagent-${type !== null && AGENT_TYPE.test(type) ? type : 'agent'}-${id.slice(0, ID_LENGTH)}`
|
|
57
|
+
: null
|
|
58
|
+
|
|
59
|
+
const textOrNull = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
|
|
60
|
+
|
|
61
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
62
|
+
|
|
63
|
+
/** The strings a tool call of a log entry carries as paths: `file_path`, `path` and the `command` of a shell call. */
|
|
64
|
+
const toolCallTexts = (entry: Record<string, unknown>): string[] => {
|
|
65
|
+
const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
|
|
66
|
+
return content.flatMap((block) => {
|
|
67
|
+
if (!isRecord(block) || block.type !== 'tool_use' || !isRecord(block.input)) return []
|
|
68
|
+
return [block.input.file_path, block.input.path, block.input.command].filter(
|
|
69
|
+
(value): value is string => typeof value === 'string',
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The last worktree below `worktreeDirectory` that a text names, as its directory; null when it names none. */
|
|
75
|
+
const lastWorktreeIn = (text: string, worktreeDirectory: string): string | null => {
|
|
76
|
+
const pattern = new RegExp(`${escapeRegExp(worktreeDirectory)}/([A-Za-z0-9._-]+)`, 'g')
|
|
77
|
+
const name = [...text.matchAll(pattern)].at(-1)?.[1]
|
|
78
|
+
return name === undefined ? null : `${worktreeDirectory}/${name}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Where the subagent works: the worktree its most recent tool call names (a path, or a path in a shell command), since
|
|
83
|
+
* a coordinator's subagent starts in the main directory and changes directory per command, so its entries carry that
|
|
84
|
+
* directory; else the directory of the last entry that names one. Lines that are no JSON (the cut-off first one) are
|
|
85
|
+
* skipped.
|
|
86
|
+
*/
|
|
87
|
+
const workingDirectory = (tail: readonly string[], worktreeDirectory: string): string | null => {
|
|
88
|
+
const entries = [...tail].reverse().flatMap((line): Record<string, unknown>[] => {
|
|
89
|
+
const entry = parseLogEntry(line)
|
|
90
|
+
return entry === null ? [] : [entry]
|
|
91
|
+
})
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
for (const text of toolCallTexts(entry).reverse()) {
|
|
94
|
+
const named = lastWorktreeIn(text, worktreeDirectory)
|
|
95
|
+
if (named !== null) return named
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const directory = entries.find((entry) => typeof entry.cwd === 'string')?.cwd
|
|
99
|
+
return typeof directory === 'string' ? directory : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The subagents at work: no `finished` mark in the meta, and the log written within `SUBAGENT_STALE_MINUTES`. One
|
|
104
|
+
* waits for an answer while the approval gate holds a request under its name (`openRequests` are the names of the open
|
|
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.
|
|
107
|
+
*/
|
|
108
|
+
export const runningSubagents = (
|
|
109
|
+
logs: readonly SubagentLog[],
|
|
110
|
+
openRequests: ReadonlySet<string>,
|
|
111
|
+
now: number,
|
|
112
|
+
worktreeDirectory: string,
|
|
113
|
+
reviewPrefixes: readonly string[],
|
|
114
|
+
): RunningSubagent[] =>
|
|
115
|
+
logs.flatMap(({ id, meta, modifiedAt, tail }) => {
|
|
116
|
+
if (!isRecord(meta) || meta.finished === true || now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE)
|
|
117
|
+
return []
|
|
118
|
+
const agentType = textOrNull(meta.agentType)
|
|
119
|
+
const label = subagentLabelOf(id, agentType)
|
|
120
|
+
if (label === null) return []
|
|
121
|
+
const description = textOrNull(typeof meta.description === 'string' ? terminalSafe(meta.description) : null)
|
|
122
|
+
const shortDescription = description?.slice(0, DESCRIPTION_LENGTH) ?? null
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
label,
|
|
126
|
+
cwd: workingDirectory(tail, worktreeDirectory),
|
|
127
|
+
description: shortDescription,
|
|
128
|
+
status: openRequests.has(label) ? AGENT_STATUS.blocked : AGENT_STATUS.working,
|
|
129
|
+
activity: activityTextOf(tail, ACTIVITY_ENTRIES),
|
|
130
|
+
phaseRun: subagentPhaseRunOf(description, agentType, modifiedAt, reviewPrefixes),
|
|
131
|
+
writtenAt: modifiedAt,
|
|
132
|
+
},
|
|
133
|
+
]
|
|
134
|
+
})
|
|
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
|
+
|
|
154
|
+
/** The worktrees below `worktreeDirectory` the subagents work in; one that waits for an answer decides the state. */
|
|
155
|
+
export const subagentsByLane = (
|
|
156
|
+
running: readonly RunningSubagent[],
|
|
157
|
+
worktreeDirectory: string,
|
|
158
|
+
): Map<string, SubagentActivity> => {
|
|
159
|
+
const lanes = new Map<string, SubagentActivity>()
|
|
160
|
+
for (const subagent of running) {
|
|
161
|
+
const { cwd, status, description, activity } = subagent
|
|
162
|
+
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
163
|
+
if (lane === null) continue
|
|
164
|
+
const known = lanes.get(lane)
|
|
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
|
+
})
|
|
179
|
+
else
|
|
180
|
+
lanes.set(lane, {
|
|
181
|
+
...(status === AGENT_STATUS.blocked && known.status !== AGENT_STATUS.blocked ? { status, description } : known),
|
|
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],
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return lanes
|
|
190
|
+
}
|
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()
|