jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- 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 +57 -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/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -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 +5 -1
- package/schemas/jackherd.schema.json +139 -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 +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- 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/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -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/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- 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 +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- 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/planned.ts +22 -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/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- 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 +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- 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 +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -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 +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- 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-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- 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
package/src/plans/lane-plans.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { type
|
|
3
|
+
import { type PlanPhase, type PlansByHref, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import type { BaseReader } from './git'
|
|
5
|
-
import { hasOwnWork, mergePlanPhases } from './phase-source'
|
|
5
|
+
import { hasOwnWork, mergePlanPhases, newerPhases } from './phase-source'
|
|
6
|
+
import { type PlanRound, parsedPlanPhases } from './plan-cache'
|
|
7
|
+
import type { PlanHead } from './plan-heads'
|
|
6
8
|
import type { PlansSettings } from './settings'
|
|
7
9
|
|
|
8
10
|
/** Paths that could not be read at the base ref and were read from a checkout instead. */
|
|
@@ -10,8 +12,20 @@ export type Fallbacks = string[]
|
|
|
10
12
|
|
|
11
13
|
export type LaneWork = { worktree: string | null; ahead: number | null; dirty: number }
|
|
12
14
|
|
|
15
|
+
/** Parses a plan's text, kept under `key` in the round's cache. */
|
|
16
|
+
type ParsePlan = (markdown: string, key: string) => PlanPhase[]
|
|
17
|
+
|
|
13
18
|
/** What reading a lane's plans needs: the main checkout, the settings, the base ref's reader, the board's directory. */
|
|
14
|
-
export type PlanSources = {
|
|
19
|
+
export type PlanSources = {
|
|
20
|
+
root: string
|
|
21
|
+
settings: PlansSettings
|
|
22
|
+
base: BaseReader
|
|
23
|
+
sprintDirectory: string
|
|
24
|
+
/** The heads of the open pull requests that change a plan, besides the lane's own; a phase they ticked further counts. */
|
|
25
|
+
heads: readonly PlanHead[]
|
|
26
|
+
/** Kept between reads, so a plan whose text did not change is not parsed again; `used` collects the keys of one round. */
|
|
27
|
+
parsed?: PlanRound
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
const isOutsideRepository = (path: string): boolean => path.startsWith('..')
|
|
17
31
|
|
|
@@ -49,10 +63,30 @@ export const laneWorkOf = (worktree: string, status: { ahead: number | null; dir
|
|
|
49
63
|
})
|
|
50
64
|
|
|
51
65
|
/**
|
|
52
|
-
* The
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
66
|
+
* The base's phases with each phase an open pull request ticked further taken from that pull request's head, in
|
|
67
|
+
* the heads' order. A head whose ref was not fetched reads nothing and changes nothing; its phases are never the
|
|
68
|
+
* lane's work, so none is marked `touched`.
|
|
69
|
+
*/
|
|
70
|
+
const withPullRequestHeads = (
|
|
71
|
+
base: PlanPhase[],
|
|
72
|
+
heads: readonly PlanHead[],
|
|
73
|
+
path: string,
|
|
74
|
+
parse: ParsePlan,
|
|
75
|
+
): PlanPhase[] =>
|
|
76
|
+
heads
|
|
77
|
+
.filter((head) => head.files.includes(path))
|
|
78
|
+
.reduce((phases, head) => {
|
|
79
|
+
const headText = head.read(path)
|
|
80
|
+
return headText === null ? phases : newerPhases(phases, parse(headText, `${path}\0pr${head.number}`))
|
|
81
|
+
}, base)
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The plans of a lane. The base ref is the truth, raised by the heads of open pull requests that ticked a phase
|
|
85
|
+
* further (`withPullRequestHeads`); while the lane has work of its own in its worktree, the phases its rows name,
|
|
86
|
+
* and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is
|
|
87
|
+
* read from the lane's worktree, else from the main checkout (a repository that ignores `docs/` keeps plan and
|
|
88
|
+
* board only there). `files` are the plan files to watch for changes, `modified` when each was last written, by
|
|
89
|
+
* plan link (`nextActivity` bounds a movement by it).
|
|
56
90
|
*/
|
|
57
91
|
export const readPlans = async (lane: SprintLane, sources: PlanSources, work: LaneWork, fallbacks: Fallbacks) => {
|
|
58
92
|
const { root, settings, sprintDirectory } = sources
|
|
@@ -68,12 +102,9 @@ export const readPlans = async (lane: SprintLane, sources: PlanSources, work: La
|
|
|
68
102
|
if (text === null) continue
|
|
69
103
|
const own = worktreeFile === file && hasOwnWork(work) ? await Bun.file(file).text() : null
|
|
70
104
|
const laneIds = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
laneIds,
|
|
75
|
-
work,
|
|
76
|
-
)
|
|
105
|
+
const parse: ParsePlan = (markdown, key) => parsedPlanPhases(sources.parsed, key, markdown, settings.planLanguage)
|
|
106
|
+
const base = withPullRequestHeads(parse(text, path), sources.heads, path, parse)
|
|
107
|
+
plans[href] = mergePlanPhases(base, own === null ? null : parse(own, `${file}\0own`), laneIds, work)
|
|
77
108
|
files.push(file)
|
|
78
109
|
texts[href] = text
|
|
79
110
|
if (existsSync(file)) modified[href] = statSync(file).mtimeMs
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isPhaseFinished,
|
|
3
3
|
isPhaseOpen,
|
|
4
|
-
isTickedUnmerged,
|
|
5
4
|
type LaneProgress,
|
|
6
5
|
type PhaseProgress,
|
|
7
6
|
type PlanPhase,
|
|
8
7
|
type PlansByHref,
|
|
9
8
|
phaseProgress,
|
|
10
9
|
} from '../plan-format'
|
|
10
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
11
11
|
import type { TaskSignal } from '../task-signals/types'
|
|
12
12
|
import { type ActiveTask, assignSignals, freshSignals, type UnplannedTask } from './active-tasks'
|
|
13
13
|
import { type ActivityTimes, activityKey } from './current-phase'
|
|
14
14
|
import { currentTasks, type TaskActivity } from './current-task'
|
|
15
|
+
import { isNamedBy } from './phase-ids'
|
|
16
|
+
import { signalledPhase } from './signalled-phase'
|
|
17
|
+
import {
|
|
18
|
+
type PhaselessReview,
|
|
19
|
+
type ReviewType,
|
|
20
|
+
type SubagentPhase,
|
|
21
|
+
type SubagentPhaseRun,
|
|
22
|
+
subagentPhases,
|
|
23
|
+
} from './subagent-phases'
|
|
24
|
+
import { type DescribedSubagent, subagentTaskPlacements } from './subagent-tasks'
|
|
15
25
|
|
|
16
26
|
/**
|
|
17
27
|
* A phase the lane works on now: an open one ticked less than `STALE_ACTIVITY_MINUTES` ago, one an agent reports, or
|
|
@@ -29,10 +39,13 @@ export type ActivePhase = {
|
|
|
29
39
|
tasks: ActiveTask[]
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
/** A phase with the step that shows it (null for none), which keys its row (`phaseKey`). */
|
|
43
|
+
export type PlacedPhase = { phase: PhaseProgress; stepIndex: number | null }
|
|
44
|
+
|
|
32
45
|
/** Open phases that do not run, by number; `planTitle` is null for the lane's current plan, the board's title for another. */
|
|
33
46
|
export type WaitingPhases = { planTitle: string | null; phaseIds: string[] }
|
|
34
47
|
|
|
35
|
-
export type FinishedPhase = { id: string
|
|
48
|
+
export type FinishedPhase = { id: string }
|
|
36
49
|
|
|
37
50
|
/** A phase the lane's board rows name that has nothing to run: `missing` when the plan lacks it, else it has no checkbox. */
|
|
38
51
|
export type UnwrittenPhase = { id: string; missing: boolean }
|
|
@@ -46,13 +59,21 @@ export type LaneSummary = {
|
|
|
46
59
|
active: ActivePhase[]
|
|
47
60
|
/** The open phase of the current plan moved last, at any age; null while none has a time. */
|
|
48
61
|
latest: PhaseProgress | null
|
|
62
|
+
/** The open phase the lane's newest report or its branch names (`signalledPhase`); it runs and the lane line names it. */
|
|
63
|
+
signalled: PhaseProgress | null
|
|
49
64
|
/** The open phases of the current plan that do not run, then the open phases of the lane's other plans. */
|
|
50
65
|
waiting: WaitingPhases[]
|
|
66
|
+
/** The phases behind `waiting`, in its order, for the rows `next` opens to. */
|
|
67
|
+
waitingPhases: PlacedPhase[]
|
|
51
68
|
finished: FinishedPhase[]
|
|
69
|
+
/** The phases behind `finished`, for the rows `✓` opens to. */
|
|
70
|
+
finishedPhases: PlacedPhase[]
|
|
52
71
|
unwritten: UnwrittenPhase[]
|
|
53
72
|
tally: PlanTally
|
|
54
73
|
/** What agents report doing that no open task of the plan names. */
|
|
55
74
|
unplanned: UnplannedTask[]
|
|
75
|
+
/** What the running subagents without a phase in their description work on, as they described it. */
|
|
76
|
+
subagentTasks: string[]
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
/**
|
|
@@ -67,13 +88,21 @@ export type SummaryInput = {
|
|
|
67
88
|
guesses?: readonly TaskActivity[]
|
|
68
89
|
/** What the lane's agents report doing (task lists, `jh task`), matched to open tasks of the current plan. */
|
|
69
90
|
reported?: readonly TaskSignal[]
|
|
91
|
+
/** The phases the descriptions of the lane's running subagents name; a signal like a report, never a tick. */
|
|
92
|
+
subagents?: readonly SubagentPhaseRun[]
|
|
93
|
+
/** The descriptions of the running subagents that name no phase; the folded lane shows them as tasks. */
|
|
94
|
+
subagentTasks?: readonly string[]
|
|
95
|
+
/** The running reviews that name no phase; they sit on the `signalled` phase, else show as tasks like `subagentTasks`. */
|
|
96
|
+
phaselessReviews?: readonly PhaselessReview[]
|
|
97
|
+
/** The running subagents that are no review; one whose description starts with a task's short name sits on it (`subagentTaskPlacements`). */
|
|
98
|
+
describedSubagents?: readonly DescribedSubagent[]
|
|
99
|
+
/** The branch checked out in the lane's worktree; a phase it names (`branchPhaseId`) is current while no report names one. */
|
|
100
|
+
branch?: string | null
|
|
70
101
|
}
|
|
71
102
|
|
|
72
103
|
/** Minutes without a tick after which a phase no longer runs, unless an agent reports it. */
|
|
73
104
|
export const STALE_ACTIVITY_MINUTES = 20
|
|
74
105
|
|
|
75
|
-
const MINUTE = 60_000
|
|
76
|
-
|
|
77
106
|
const isWritten = ({ total }: PhaseProgress): boolean => total > 0
|
|
78
107
|
|
|
79
108
|
const planHrefs = ({ steps }: LaneProgress): string[] => [
|
|
@@ -92,18 +121,12 @@ const stepPhases = ({ steps }: LaneProgress, href: string): PhaseProgress[] => {
|
|
|
92
121
|
return shown.filter((phase, index) => shown.findIndex(({ id }) => id === phase.id) === index)
|
|
93
122
|
}
|
|
94
123
|
|
|
95
|
-
const SUB_PHASE_LETTERS = /[a-z]+$/i
|
|
96
|
-
|
|
97
124
|
/** The ids the lane's steps on `href` name, a row's repeats and the phases it worked on without a row included. */
|
|
98
125
|
const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
|
|
99
126
|
new Set(
|
|
100
127
|
steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
|
|
101
128
|
)
|
|
102
129
|
|
|
103
|
-
/** A plan phase the lane names: by its own id, or a sub-phase (`10c`) of a phase it names (`P10`). */
|
|
104
|
-
const isNamed = (id: string, named: ReadonlySet<string>): boolean =>
|
|
105
|
-
named.has(id) || named.has(id.replace(SUB_PHASE_LETTERS, ''))
|
|
106
|
-
|
|
107
130
|
/**
|
|
108
131
|
* The phases of `href` the lane names, in plan order: another lane's phases of the same plan are not its own, however
|
|
109
132
|
* lately they moved.
|
|
@@ -112,7 +135,7 @@ const phasesOf = (input: SummaryInput, href: string): PhaseProgress[] => {
|
|
|
112
135
|
const plan: readonly PlanPhase[] | undefined = input.phases[href]
|
|
113
136
|
if (!plan) return stepPhases(input.progress, href)
|
|
114
137
|
const named = namedIds(input.progress, href)
|
|
115
|
-
return plan.filter(({ id }) =>
|
|
138
|
+
return plan.filter(({ id }) => isNamedBy(id, named)).map(({ id }) => phaseProgress(plan, id))
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): number | null => {
|
|
@@ -135,56 +158,166 @@ const isStarted = ({ items }: PhaseProgress): boolean => {
|
|
|
135
158
|
return tasks.some(({ done }) => done) && tasks.some(({ done }) => !done)
|
|
136
159
|
}
|
|
137
160
|
|
|
161
|
+
const minutesSince = (at: number, now: number): number => Math.max(Math.floor((now - at) / MS_PER_MINUTE), 0)
|
|
162
|
+
|
|
138
163
|
/** The open phases with a time of their last movement, the one moved last first; a phase without a time is absent. */
|
|
139
164
|
const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[], now: number): TimedPhase[] =>
|
|
140
165
|
phases
|
|
141
166
|
.filter(isPhaseOpen)
|
|
142
167
|
.flatMap((phase) => {
|
|
143
168
|
const at = input.activity[activityKey(href, phase.id)]
|
|
144
|
-
return at === undefined ? [] : [{ phase, minutesAgo:
|
|
169
|
+
return at === undefined ? [] : [{ phase, minutesAgo: minutesSince(at, now) }]
|
|
145
170
|
})
|
|
146
171
|
.sort((first, second) => first.minutesAgo - second.minutesAgo)
|
|
147
172
|
|
|
148
173
|
const newest = (times: readonly number[]): number | null => (times.length > 0 ? Math.max(...times) : null)
|
|
149
174
|
|
|
150
|
-
const minutesSince = (at: number, now: number): number => Math.max(Math.floor((now - at) / MINUTE), 0)
|
|
151
|
-
|
|
152
175
|
const isListed = (listed: readonly RunningPhase[], phase: PhaseProgress): boolean =>
|
|
153
176
|
listed.some((each) => each.phase.id === phase.id)
|
|
154
177
|
|
|
155
|
-
/**
|
|
178
|
+
/** What names a phase as running besides its ticks, by phase id: the tasks agents reported and the subagents at work. */
|
|
179
|
+
type PhaseSignals = {
|
|
180
|
+
reported: ReadonlyMap<string, ActiveTask[]>
|
|
181
|
+
subagents: ReadonlyMap<string, SubagentPhase>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** When the newest report or running subagent of a phase began, in milliseconds; null for none. */
|
|
185
|
+
const reportedAt = (id: string, { reported, subagents }: PhaseSignals): number | null => {
|
|
186
|
+
const subagent = subagents.get(id)
|
|
187
|
+
return newest([
|
|
188
|
+
...(reported.get(id) ?? []).flatMap((task) => task.startedAt ?? []),
|
|
189
|
+
...(subagent === undefined ? [] : [subagent.at]),
|
|
190
|
+
])
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The open phases a reported task or a running subagent names that are not `listed` yet, aged from the newest of them. */
|
|
156
194
|
const reportedPhases = (
|
|
157
195
|
phases: PhaseProgress[],
|
|
158
|
-
|
|
196
|
+
signals: PhaseSignals,
|
|
159
197
|
listed: readonly RunningPhase[],
|
|
160
198
|
now: number,
|
|
161
199
|
): RunningPhase[] =>
|
|
162
200
|
phases
|
|
163
|
-
.filter(
|
|
201
|
+
.filter(
|
|
202
|
+
(phase) =>
|
|
203
|
+
isPhaseOpen(phase) &&
|
|
204
|
+
(signals.reported.has(phase.id) || signals.subagents.has(phase.id)) &&
|
|
205
|
+
!isListed(listed, phase),
|
|
206
|
+
)
|
|
164
207
|
.map((phase) => {
|
|
165
|
-
const startedAt =
|
|
208
|
+
const startedAt = reportedAt(phase.id, signals)
|
|
166
209
|
return { phase, minutesAgo: startedAt === null ? 0 : minutesSince(startedAt, now) }
|
|
167
210
|
})
|
|
168
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Whether a text names the review type as a word (`/security-audit`, `` `uncle-bob` ``): `review` is not named by
|
|
214
|
+
* `skill-review` or `Review-Gate`. The types hold no character a pattern reads specially.
|
|
215
|
+
*/
|
|
216
|
+
const namesReview = (text: string, review: ReviewType): boolean =>
|
|
217
|
+
new RegExp(`(?<![\\w-])${review}(?![\\w-])`).test(text)
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The review gate box a running review sits on, which gets its `▶` like a reported task: the open one that names the
|
|
221
|
+
* review, else the first open one. A phase whose gate is ticked or that has none shows no arrow.
|
|
222
|
+
*/
|
|
223
|
+
const reviewTask = ({ items }: PhaseProgress, review: ReviewType, at: number): ActiveTask[] => {
|
|
224
|
+
const isOpenGate = (item: PhaseProgress['items'][number]): boolean => item.gate === true && !item.done
|
|
225
|
+
const named = items.findIndex((item) => isOpenGate(item) && namesReview(item.text, review))
|
|
226
|
+
const index = named === -1 ? items.findIndex(isOpenGate) : named
|
|
227
|
+
return index === -1 ? [] : [{ index, agent: null, guessed: false, startedAt: at }]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Adds `tasks` to the phase's tasks in `merged`, each task once. */
|
|
231
|
+
const addTasks = (merged: Map<string, ActiveTask[]>, phaseId: string, tasks: readonly ActiveTask[]): void => {
|
|
232
|
+
const known = merged.get(phaseId) ?? []
|
|
233
|
+
merged.set(phaseId, [...known, ...tasks.filter(({ index }) => !known.some((task) => task.index === index))])
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The reported tasks with the gate box each running review of a phase sits on added, unless a report names it already. */
|
|
237
|
+
const withReviewTasks = (
|
|
238
|
+
reported: ReadonlyMap<string, ActiveTask[]>,
|
|
239
|
+
phases: PhaseProgress[],
|
|
240
|
+
subagents: ReadonlyMap<string, SubagentPhase>,
|
|
241
|
+
): Map<string, ActiveTask[]> => {
|
|
242
|
+
const merged = new Map(reported)
|
|
243
|
+
for (const phase of phases) {
|
|
244
|
+
const found = subagents.get(phase.id)
|
|
245
|
+
if (!found) continue
|
|
246
|
+
for (const review of found.reviews) addTasks(merged, phase.id, reviewTask(phase, review, found.at))
|
|
247
|
+
}
|
|
248
|
+
return merged
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The reported tasks with the tasks the subagents sit on added (`subagentTaskPlacements`), unless a report names them already. */
|
|
252
|
+
const withTasks = (
|
|
253
|
+
reported: ReadonlyMap<string, ActiveTask[]>,
|
|
254
|
+
added: ReadonlyMap<string, ActiveTask[]>,
|
|
255
|
+
): Map<string, ActiveTask[]> => {
|
|
256
|
+
const merged = new Map(reported)
|
|
257
|
+
for (const [phaseId, tasks] of added) addTasks(merged, phaseId, tasks)
|
|
258
|
+
return merged
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The signalled phase when a review can sit on it: one with an open gate box, else none. */
|
|
262
|
+
const reviewPhaseOf = (signalled: PhaseProgress | null): PhaseProgress | null =>
|
|
263
|
+
signalled?.items.some((item) => item.gate && !item.done) ? signalled : null
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The runs of the lane's subagents, its reviews that name no phase placed on the `signalled` phase while its gate has
|
|
267
|
+
* an open box; otherwise they stay unplaced and show as tasks (`unplacedTasks`), so a running review never vanishes.
|
|
268
|
+
*/
|
|
269
|
+
const placedRuns = (input: SummaryInput, signalled: PhaseProgress | null): SubagentPhaseRun[] => {
|
|
270
|
+
const phase = reviewPhaseOf(signalled)
|
|
271
|
+
return [
|
|
272
|
+
...(input.subagents ?? []),
|
|
273
|
+
...(phase === null
|
|
274
|
+
? []
|
|
275
|
+
: (input.phaselessReviews ?? []).map(({ review, at }) => ({ ids: [phase.id], review, at }))),
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The descriptions of the subagents that name no phase and sit on no task (`placed`), then those of the reviews no
|
|
281
|
+
* signalled phase takes.
|
|
282
|
+
*/
|
|
283
|
+
const unplacedTasks = (input: SummaryInput, signalled: PhaseProgress | null, placed: readonly string[]): string[] => [
|
|
284
|
+
...(input.subagentTasks ?? []).filter((description) => !placed.includes(description)),
|
|
285
|
+
...(reviewPhaseOf(signalled) === null
|
|
286
|
+
? (input.phaselessReviews ?? []).flatMap(({ description }) => (description === null ? [] : [description]))
|
|
287
|
+
: []),
|
|
288
|
+
]
|
|
289
|
+
|
|
169
290
|
/** The open phases with a ticked task (`isStarted`) that are not `listed` yet, aged from their last tick when one is known. */
|
|
170
291
|
const startedPhases = (phases: PhaseProgress[], timed: TimedPhase[], listed: readonly RunningPhase[]): RunningPhase[] =>
|
|
171
292
|
phases
|
|
172
293
|
.filter((phase) => isPhaseOpen(phase) && isStarted(phase) && !isListed(listed, phase))
|
|
173
294
|
.map((phase) => ({ phase, minutesAgo: timed.find((each) => each.phase.id === phase.id)?.minutesAgo ?? null }))
|
|
174
295
|
|
|
296
|
+
/** The signalled phase when no other source lists it yet, aged from its last tick when one is known. */
|
|
297
|
+
const signalledRunning = (
|
|
298
|
+
signalled: PhaseProgress | null,
|
|
299
|
+
timed: TimedPhase[],
|
|
300
|
+
listed: readonly RunningPhase[],
|
|
301
|
+
): RunningPhase[] =>
|
|
302
|
+
signalled && !isListed(listed, signalled)
|
|
303
|
+
? [{ phase: signalled, minutesAgo: timed.find((each) => each.phase.id === signalled.id)?.minutesAgo ?? null }]
|
|
304
|
+
: []
|
|
305
|
+
|
|
175
306
|
/**
|
|
176
|
-
* The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task
|
|
177
|
-
* names,
|
|
178
|
-
* known, else none
|
|
307
|
+
* The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task or a
|
|
308
|
+
* running subagent names, the signalled phase (`signalledPhase`), then every other open phase with a ticked task
|
|
309
|
+
* (`isStarted`), with the age of its last tick when one is known, else none. A phase only written down, without a
|
|
310
|
+
* tick, a report or the lane's branch naming it, waits.
|
|
179
311
|
*/
|
|
180
312
|
const runningPhases = (
|
|
181
313
|
timed: TimedPhase[],
|
|
182
314
|
phases: PhaseProgress[],
|
|
183
|
-
|
|
315
|
+
signals: PhaseSignals & { signalled: PhaseProgress | null },
|
|
184
316
|
now: number,
|
|
185
317
|
): RunningPhase[] => {
|
|
186
318
|
const moved = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
|
|
187
|
-
const
|
|
319
|
+
const reported = [...moved, ...reportedPhases(phases, signals, moved, now)]
|
|
320
|
+
const listed = [...reported, ...signalledRunning(signals.signalled, timed, reported)]
|
|
188
321
|
return [...listed, ...startedPhases(phases, timed, listed)]
|
|
189
322
|
}
|
|
190
323
|
|
|
@@ -193,7 +326,7 @@ const runningPhases = (
|
|
|
193
326
|
* passes no phase while an agent reports, so a lane with a report guesses nothing.
|
|
194
327
|
*/
|
|
195
328
|
const guessedTasks = (input: SummaryInput, phases: PhaseProgress[], now: number): ReadonlyMap<string, number> =>
|
|
196
|
-
currentTasks(phases, input.guesses ?? [], now, STALE_ACTIVITY_MINUTES *
|
|
329
|
+
currentTasks(phases, input.guesses ?? [], now, STALE_ACTIVITY_MINUTES * MS_PER_MINUTE)
|
|
197
330
|
|
|
198
331
|
/** The running phases, each with the tasks agents reported on it, else its task in `guessed`, marked as guessed. */
|
|
199
332
|
const activeOf = (
|
|
@@ -234,6 +367,16 @@ const otherPlans = (input: SummaryInput, hrefs: string[]): WaitingPhases[] =>
|
|
|
234
367
|
return open.length > 0 ? [{ planTitle: titleOf(input.progress, href), phaseIds: open.map(({ id }) => id) }] : []
|
|
235
368
|
})
|
|
236
369
|
|
|
370
|
+
const byPhaseNumber = (first: PhaseProgress, second: PhaseProgress): number =>
|
|
371
|
+
first.id.localeCompare(second.id, undefined, { numeric: true })
|
|
372
|
+
|
|
373
|
+
/** The open phases of the current plan that do not run, by number. */
|
|
374
|
+
const waitingIn = (phases: PhaseProgress[], running: ReadonlySet<string>): PhaseProgress[] =>
|
|
375
|
+
phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).sort(byPhaseNumber)
|
|
376
|
+
|
|
377
|
+
const otherHrefs = (input: SummaryInput, href: string): string[] =>
|
|
378
|
+
planHrefs(input.progress).filter((each) => each !== href)
|
|
379
|
+
|
|
237
380
|
/** The open phases of the current plan that do not run, by number, then the open phases of the lane's other plans. */
|
|
238
381
|
const waitingOf = (
|
|
239
382
|
input: SummaryInput,
|
|
@@ -241,24 +384,75 @@ const waitingOf = (
|
|
|
241
384
|
phases: PhaseProgress[],
|
|
242
385
|
running: ReadonlySet<string>,
|
|
243
386
|
): WaitingPhases[] => {
|
|
244
|
-
const waiting = phases
|
|
245
|
-
|
|
246
|
-
.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
input,
|
|
250
|
-
planHrefs(input.progress).filter((each) => each !== href),
|
|
251
|
-
)
|
|
252
|
-
return [...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []), ...others]
|
|
387
|
+
const waiting = waitingIn(phases, running).map(({ id }) => id)
|
|
388
|
+
return [
|
|
389
|
+
...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []),
|
|
390
|
+
...otherPlans(input, otherHrefs(input, href)),
|
|
391
|
+
]
|
|
253
392
|
}
|
|
254
393
|
|
|
255
|
-
|
|
394
|
+
const placedIn =
|
|
395
|
+
(progress: LaneProgress, href: string) =>
|
|
396
|
+
(phase: PhaseProgress): PlacedPhase => ({ phase, stepIndex: newestStepShowing(progress, href, phase.id) })
|
|
397
|
+
|
|
398
|
+
/** The phases behind `waitingOf`, in its order, each with the step that shows it. */
|
|
399
|
+
const waitingPlaced = (
|
|
400
|
+
input: SummaryInput,
|
|
401
|
+
href: string,
|
|
402
|
+
phases: PhaseProgress[],
|
|
403
|
+
running: ReadonlySet<string>,
|
|
404
|
+
): PlacedPhase[] => [
|
|
405
|
+
...waitingIn(phases, running).map(placedIn(input.progress, href)),
|
|
406
|
+
...otherHrefs(input, href).flatMap((other) =>
|
|
407
|
+
phasesOf(input, other).filter(isPhaseOpen).map(placedIn(input.progress, other)),
|
|
408
|
+
),
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
/** The finished phases of the current plan. */
|
|
256
412
|
const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
|
|
257
|
-
phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id
|
|
413
|
+
phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id }))
|
|
414
|
+
|
|
415
|
+
type Resting = Pick<LaneSummary, 'waiting' | 'waitingPhases' | 'finished' | 'finishedPhases' | 'unwritten'>
|
|
416
|
+
|
|
417
|
+
/** What does not run: the phases that wait or are finished, as ids and as phases to open, and those without a checkbox. */
|
|
418
|
+
const restingOf = (input: SummaryInput, href: string, phases: PhaseProgress[], active: ActivePhase[]): Resting => {
|
|
419
|
+
const running = new Set(active.map(({ phase }) => phase.id))
|
|
420
|
+
return {
|
|
421
|
+
waiting: waitingOf(input, href, phases, running),
|
|
422
|
+
waitingPhases: waitingPlaced(input, href, phases, running),
|
|
423
|
+
finished: finishedOf(phases),
|
|
424
|
+
finishedPhases: phases.filter(isPhaseFinished).map(placedIn(input.progress, href)),
|
|
425
|
+
unwritten: stepPhases(input.progress, href)
|
|
426
|
+
.filter((phase) => !isWritten(phase))
|
|
427
|
+
.map(({ id, title }) => ({ id, missing: title === null })),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
type ActiveSources = {
|
|
432
|
+
phases: PhaseProgress[]
|
|
433
|
+
timed: TimedPhase[]
|
|
434
|
+
/** The tasks agents reported, by phase id. */
|
|
435
|
+
reported: ReadonlyMap<string, ActiveTask[]>
|
|
436
|
+
/** Whether any report is fresh; the herd guesses no task then. */
|
|
437
|
+
anyFresh: boolean
|
|
438
|
+
signalled: PhaseProgress | null
|
|
439
|
+
/** The runs of the lane's subagents, the reviews that name no phase placed (`placedRuns`). */
|
|
440
|
+
subagents: readonly SubagentPhaseRun[]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The running phases of the plan on `href`, each with the tasks reported on it, the review gate or the guessed one. */
|
|
444
|
+
const activePhases = (input: SummaryInput, href: string, sources: ActiveSources, now: number): ActivePhase[] => {
|
|
445
|
+
const { phases, timed, reported, anyFresh, signalled } = sources
|
|
446
|
+
const named = subagentPhases(phases, sources.subagents)
|
|
447
|
+
const running = runningPhases(timed, phases, { reported, subagents: named, signalled }, now)
|
|
448
|
+
const tasks = withReviewTasks(reported, phases, named)
|
|
449
|
+
const guessable = anyFresh ? [] : running.map(({ phase }) => phase).filter(({ id }) => !tasks.has(id))
|
|
450
|
+
return activeOf(input, href, running, tasks, guessedTasks(input, guessable, now))
|
|
451
|
+
}
|
|
258
452
|
|
|
259
453
|
/**
|
|
260
454
|
* The lane summed up over its plans at `now`: the open phases running in its current plan (`runningPhases`), the open
|
|
261
|
-
* phase moved last, the open phases that wait, the finished ones
|
|
455
|
+
* phase moved last, the open phases that wait, the finished ones, the phases its rows
|
|
262
456
|
* name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
|
|
263
457
|
* included, is finished, never running.
|
|
264
458
|
*/
|
|
@@ -266,24 +460,38 @@ export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
|
|
|
266
460
|
const href = focusHref(input.progress)
|
|
267
461
|
const tally = tallyOf(planHrefs(input.progress).flatMap((each) => phasesOf(input, each)))
|
|
268
462
|
if (href === null) {
|
|
269
|
-
|
|
463
|
+
const subagentTasks = unplacedTasks(input, null, [])
|
|
464
|
+
return {
|
|
465
|
+
active: [],
|
|
466
|
+
latest: null,
|
|
467
|
+
signalled: null,
|
|
468
|
+
waiting: [],
|
|
469
|
+
waitingPhases: [],
|
|
470
|
+
finished: [],
|
|
471
|
+
finishedPhases: [],
|
|
472
|
+
unwritten: [],
|
|
473
|
+
tally,
|
|
474
|
+
unplanned: [],
|
|
475
|
+
subagentTasks,
|
|
476
|
+
}
|
|
270
477
|
}
|
|
271
478
|
const phases = phasesOf(input, href)
|
|
272
479
|
const timed = timedPhases(input, href, phases, now)
|
|
273
480
|
const fresh = freshSignals(input.reported ?? [], newest(Object.values(input.activity)))
|
|
274
481
|
const { byPhase, unplanned } = assignSignals(phases, fresh)
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
const
|
|
482
|
+
const signalled = signalledPhase(phases, byPhase, input.branch ?? null)
|
|
483
|
+
const subagents = placedRuns(input, signalled)
|
|
484
|
+
const onTasks = subagentTaskPlacements(phases, input.describedSubagents ?? [])
|
|
485
|
+
const reported = withTasks(byPhase, onTasks.byPhase)
|
|
486
|
+
const sources = { phases, timed, reported, anyFresh: fresh.length > 0, signalled, subagents }
|
|
487
|
+
const active = activePhases(input, href, sources, now)
|
|
278
488
|
return {
|
|
279
489
|
active,
|
|
280
490
|
latest: timed[0]?.phase ?? null,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
unwritten: stepPhases(input.progress, href)
|
|
284
|
-
.filter((phase) => !isWritten(phase))
|
|
285
|
-
.map(({ id, title }) => ({ id, missing: title === null })),
|
|
491
|
+
signalled,
|
|
492
|
+
...restingOf(input, href, phases, active),
|
|
286
493
|
tally,
|
|
287
494
|
unplanned,
|
|
495
|
+
subagentTasks: unplacedTasks(input, signalled, onTasks.placed),
|
|
288
496
|
}
|
|
289
497
|
}
|
|
@@ -11,6 +11,7 @@ import type { LinkChip } from './link-chips'
|
|
|
11
11
|
import type { PhaseModel } from './phase-models'
|
|
12
12
|
import { itemKey, type Selection, tickedKey } from './selection'
|
|
13
13
|
import { chevronOf, MODEL_MARK, PHASE_COUNT, PHASE_STATE, type PhaseState, phaseStateOf } from './symbols'
|
|
14
|
+
import { taskBodyOf, taskNameOf } from './task-name'
|
|
14
15
|
import { type BadgeTexts, TEXTS } from './texts'
|
|
15
16
|
|
|
16
17
|
/** What every block of one lane is drawn with. */
|
|
@@ -57,6 +58,9 @@ const PHASE_MARK: Record<PhaseState, string> = {
|
|
|
57
58
|
|
|
58
59
|
export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
|
|
59
60
|
|
|
61
|
+
/** What a task reads as on one row: its short name where it carries one (`taskNameOf`), else its whole text. */
|
|
62
|
+
export const taskTitle = (text: string): string => plain(taskNameOf(text) ?? text)
|
|
63
|
+
|
|
60
64
|
export const countText = (done: number, total: number): string =>
|
|
61
65
|
`${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
|
|
62
66
|
|
|
@@ -117,9 +121,16 @@ export const agentCell = (agent: string | null): string => (agent ? paint.dim(te
|
|
|
117
121
|
/** Two cells: a task row hangs under its phase's label, which an arrow would otherwise push right. */
|
|
118
122
|
export const TASK_INDENT = ' '
|
|
119
123
|
|
|
124
|
+
/** `▶ <label> <text>`, the text bold, the agent dim at the right edge. */
|
|
125
|
+
const labelledLine = (label: string, { text, agent }: UnplannedTask, width: number): string =>
|
|
126
|
+
spread(`${activeArrow(false)} ${label} ${paint.bold(plain(terminalSafe(text)))}`, agentCell(agent), width)
|
|
127
|
+
|
|
120
128
|
/** `▶ now: <text>`: what an agent reports that no task of the plan names; the agent dim at the right edge. */
|
|
121
|
-
export const nowLine = (
|
|
122
|
-
|
|
129
|
+
export const nowLine = (task: UnplannedTask, width: number): string => labelledLine(TEXTS.now, task, width)
|
|
130
|
+
|
|
131
|
+
/** `▶ Subagent: <description>`: a running subagent that names no phase and sits on none. */
|
|
132
|
+
export const subagentLine = (description: string, width: number): string =>
|
|
133
|
+
labelledLine(TEXTS.subagent, { text: description, agent: null }, width)
|
|
123
134
|
|
|
124
135
|
type TaskLook = { mark: string; style: (line: string) => string; suffix: string }
|
|
125
136
|
|
|
@@ -137,15 +148,26 @@ const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined): Task
|
|
|
137
148
|
: { mark: '☐', style: (line) => line, suffix: '' }
|
|
138
149
|
}
|
|
139
150
|
|
|
140
|
-
/** A closed task as one row, cut at the pane's edge with `…`. */
|
|
151
|
+
/** A closed task as one row, its short name or its text cut at the pane's edge with `…`. */
|
|
141
152
|
const closedTaskRow = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
|
|
142
153
|
const { mark, style } = taskLook(item, active)
|
|
143
|
-
return [spread(`${mark} ${style(
|
|
154
|
+
return [spread(`${mark} ${style(taskTitle(item.text))}`, agentCell(active?.agent ?? null), width)]
|
|
144
155
|
}
|
|
145
156
|
|
|
146
|
-
/**
|
|
147
|
-
const
|
|
148
|
-
|
|
157
|
+
/** A named task opened: its name bold beside the mark, below it the line after the name (`taskBodyOf`) wrapped. */
|
|
158
|
+
const namedTaskRows = (name: string, item: ChecklistItem, look: TaskLook, width: number): string[] => {
|
|
159
|
+
const body = wrap(plain(taskBodyOf(item.text)), Math.max(width - 2, 1)).map(
|
|
160
|
+
(line) => ` ${item.done ? paint.dim(line) : line}`,
|
|
161
|
+
)
|
|
162
|
+
return [fit(`${look.mark} ${paint.bold(plain(name))}${look.suffix}`, width), ...body]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** An open task as rows: a named one by `namedTaskRows`, else its whole text wrapped. */
|
|
166
|
+
const openTaskRows = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
|
|
167
|
+
const name = taskNameOf(item.text)
|
|
168
|
+
const look = taskLook(item, active)
|
|
169
|
+
return name ? namedTaskRows(name, item, look, width) : markedLines(item.text, look, width)
|
|
170
|
+
}
|
|
149
171
|
|
|
150
172
|
/** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
|
|
151
173
|
export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
|
|
@@ -155,8 +177,11 @@ export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext
|
|
|
155
177
|
const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
|
|
156
178
|
fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
|
|
157
179
|
|
|
158
|
-
|
|
159
|
-
|
|
180
|
+
/** The clickable line of a phase's ticked tasks: `▸ <n> ticked · …lists them`, or `▾ …folds them` above the listed ones. */
|
|
181
|
+
const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number, listed: boolean): Frame => ({
|
|
182
|
+
lines: [
|
|
183
|
+
fit(paint.dim(`${chevronOf(listed)} ${listed ? TEXTS.tickedFold(count) : TEXTS.tickedSummary(count)}`), width),
|
|
184
|
+
],
|
|
160
185
|
cursorLine: -1,
|
|
161
186
|
targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey), wholeLine: true }],
|
|
162
187
|
})
|
|
@@ -181,7 +206,7 @@ export const phaseChildren = (
|
|
|
181
206
|
const framesWhere = (done: boolean): Frame[] =>
|
|
182
207
|
phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
|
|
183
208
|
const tickedCount = phase.items.filter((item) => item.done).length
|
|
184
|
-
const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
|
|
209
|
+
const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width, state.showDone)] : []
|
|
185
210
|
const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
|
|
186
|
-
return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) :
|
|
211
|
+
return [...framesWhere(false), ...deferred, ...summary, ...(state.showDone ? framesWhere(true) : [])]
|
|
187
212
|
}
|