jackherd 1.6.0 → 1.7.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 +31 -0
- package/INSTALL.md +176 -0
- package/README.md +260 -315
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
package/src/plans/snapshot.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { agentLog } from '../context/session-log'
|
|
|
6
6
|
import {
|
|
7
7
|
type LaneProgress,
|
|
8
8
|
laneProgress,
|
|
9
|
+
type PlansByHref,
|
|
9
10
|
readSprintDocument,
|
|
10
11
|
resolvePlanPath,
|
|
11
12
|
type SprintLane,
|
|
@@ -14,16 +15,20 @@ import {
|
|
|
14
15
|
} from '../plan-format'
|
|
15
16
|
import type { LaneAgent, LaneStatusSource } from '../terminal'
|
|
16
17
|
import type { PlanLanguage } from '../vocabulary'
|
|
17
|
-
import type { WatchBadges, WatchHealth } from '../watch/plan-badges'
|
|
18
|
+
import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
|
|
18
19
|
import { readWatchReading } from '../watch/plan-badges-read'
|
|
19
20
|
import { boardRowsOf } from './board-rows'
|
|
21
|
+
import { type LaneMemory, newestRowIdentity, nextMemory, withMovedPhase } from './current-phase'
|
|
20
22
|
import { errorText } from './error-text'
|
|
21
23
|
import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
|
|
22
24
|
import type { PullRequestState } from './github'
|
|
23
25
|
import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
|
|
26
|
+
import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
|
|
24
27
|
import { type LaneView, sortByUrgency } from './layout'
|
|
25
28
|
import { extractLinkChips } from './link-chips'
|
|
26
29
|
import type { Options } from './options'
|
|
30
|
+
import { laneModelsOf, type PhaseModelRecord } from './phase-models'
|
|
31
|
+
import { readPhaseModels } from './phase-models-read'
|
|
27
32
|
import { currentPlanFirst, followingPlans, type PlannedPlan, plannedPlans } from './planned'
|
|
28
33
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
|
|
29
34
|
import { boardTexts } from './texts'
|
|
@@ -68,8 +73,13 @@ export type Snapshot = {
|
|
|
68
73
|
files: string[]
|
|
69
74
|
titles: Map<string, string | null>
|
|
70
75
|
errors: string[]
|
|
71
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* The watcher's health; absent when no lane has an open pull request and no source is marked `ticket`, so the
|
|
78
|
+
* watcher was not looked at.
|
|
79
|
+
*/
|
|
72
80
|
watch?: WatchHealth
|
|
81
|
+
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
82
|
+
watchesTickets?: boolean
|
|
73
83
|
/** The project's response language, for the words of the watcher's badges. */
|
|
74
84
|
language?: PlanLanguage
|
|
75
85
|
}
|
|
@@ -114,7 +124,28 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
|
|
|
114
124
|
}
|
|
115
125
|
}
|
|
116
126
|
|
|
117
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The progress of a lane with the phase whose boxes moved since the last read as its current one: the lane's memory
|
|
129
|
+
* in `memories` is updated by this read. Without `memories` the newest board row decides alone.
|
|
130
|
+
*/
|
|
131
|
+
const progressWithMemory = (
|
|
132
|
+
shown: SprintLane,
|
|
133
|
+
plans: PlansByHref,
|
|
134
|
+
memories: Map<string, LaneMemory> | undefined,
|
|
135
|
+
): LaneProgress => {
|
|
136
|
+
const progress = laneProgress(shown, plans)
|
|
137
|
+
if (!memories) return progress
|
|
138
|
+
const memory = nextMemory(memories.get(shown.name), plans, newestRowIdentity(progress))
|
|
139
|
+
memories.set(shown.name, memory)
|
|
140
|
+
return withMovedPhase(progress, memory.moved)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const laneView = async (
|
|
144
|
+
sources: LaneSources,
|
|
145
|
+
lane: SprintLane,
|
|
146
|
+
fallbacks: Fallbacks,
|
|
147
|
+
memories?: Map<string, LaneMemory>,
|
|
148
|
+
): Promise<LaneResult> => {
|
|
118
149
|
const { source, agents } = sources
|
|
119
150
|
const { root, settings } = source
|
|
120
151
|
const worktree = worktreeOf(root, settings, lane.name)
|
|
@@ -127,7 +158,7 @@ const laneView = async (sources: LaneSources, lane: SprintLane, fallbacks: Fallb
|
|
|
127
158
|
fallbacks,
|
|
128
159
|
)
|
|
129
160
|
const shown = withWorkedPhases(lane, plans)
|
|
130
|
-
const progress =
|
|
161
|
+
const progress = progressWithMemory(shown, plans, memories)
|
|
131
162
|
const view: LaneView = {
|
|
132
163
|
progress,
|
|
133
164
|
...running,
|
|
@@ -171,6 +202,23 @@ const withBadges = (view: LaneView, badges: WatchBadges): LaneView => {
|
|
|
171
202
|
return Object.keys(own).length > 0 ? { ...view, badges: own } : view
|
|
172
203
|
}
|
|
173
204
|
|
|
205
|
+
/** The view with the model each of its phases last ran on, only when there is one, so it otherwise stays as it was. */
|
|
206
|
+
const withModels = (view: LaneView, records: readonly PhaseModelRecord[], sprintDirectory: string): LaneView => {
|
|
207
|
+
const models = laneModelsOf(view.progress, records, sprintDirectory)
|
|
208
|
+
return Object.keys(models).length > 0 ? { ...view, models } : view
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The view with the lane's tickets, found by its source lane or by its id in the board rows and plan texts of the
|
|
213
|
+
* lane; a link chip with the URL of a shown ticket gives way to the badge. Without tickets it stays as it was.
|
|
214
|
+
*/
|
|
215
|
+
const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): LaneView => {
|
|
216
|
+
const { view, texts } = result
|
|
217
|
+
const lane = view.progress.lane
|
|
218
|
+
const found = laneTicketsOf(tickets, lane.name, [...boardRowTexts(lane), ...Object.values(texts)])
|
|
219
|
+
return found ? { ...view, chips: withoutTicketLinks(view.chips, found), tickets: found } : view
|
|
220
|
+
}
|
|
221
|
+
|
|
174
222
|
/** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
|
|
175
223
|
const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
|
|
176
224
|
try {
|
|
@@ -234,7 +282,20 @@ const readListedPlans = async (
|
|
|
234
282
|
return Object.fromEntries(entries.flat())
|
|
235
283
|
}
|
|
236
284
|
|
|
237
|
-
|
|
285
|
+
const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: readonly SprintLane[]): void => {
|
|
286
|
+
const names = new Set(lanes.map((lane) => lane.name))
|
|
287
|
+
for (const name of [...(memories?.keys() ?? [])]) if (!names.has(name)) memories?.delete(name)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
|
|
292
|
+
* is how the current phase becomes the one whose boxes moved since, and it is cleaned of lanes the board dropped.
|
|
293
|
+
*/
|
|
294
|
+
export const loadSnapshot = async (
|
|
295
|
+
source: Source,
|
|
296
|
+
pullRequests: PullRequestState,
|
|
297
|
+
memories?: Map<string, LaneMemory>,
|
|
298
|
+
): Promise<Snapshot> => {
|
|
238
299
|
const { root, sprintFile, options, settings, terminal } = source
|
|
239
300
|
const fallbacks: Fallbacks = []
|
|
240
301
|
const base = baseReaderOf(root, baseRefOf(settings))
|
|
@@ -258,9 +319,10 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
|
|
|
258
319
|
pullRequests,
|
|
259
320
|
}
|
|
260
321
|
const results = [
|
|
261
|
-
...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks)))),
|
|
322
|
+
...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, memories)))),
|
|
262
323
|
...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
|
|
263
324
|
]
|
|
325
|
+
forgetLanes(memories, lanes)
|
|
264
326
|
const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
|
|
265
327
|
const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
|
|
266
328
|
const planned = options.lane
|
|
@@ -271,14 +333,25 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
|
|
|
271
333
|
sprintDirectory,
|
|
272
334
|
language: settings.planLanguage,
|
|
273
335
|
})
|
|
274
|
-
const
|
|
336
|
+
const { ticketSources } = settings
|
|
337
|
+
const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
338
|
+
const reading = watched ? readWatchReading(root, Date.now(), ticketSources) : null
|
|
339
|
+
const phaseModels = readPhaseModels(root)
|
|
275
340
|
const baseRef = baseRefOf(settings)
|
|
276
341
|
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
277
342
|
const unknownLaneTitle =
|
|
278
343
|
options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
|
|
279
344
|
return {
|
|
280
345
|
title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
|
|
281
|
-
views: sortByUrgency(
|
|
346
|
+
views: sortByUrgency(
|
|
347
|
+
results.map((result) =>
|
|
348
|
+
withModels(
|
|
349
|
+
withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
|
|
350
|
+
phaseModels,
|
|
351
|
+
sprintDirectory,
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
),
|
|
282
355
|
documents: {
|
|
283
356
|
sprint: markdown,
|
|
284
357
|
plans: documentsPlans,
|
|
@@ -295,6 +368,7 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
|
|
|
295
368
|
...(source.boardNotice ? [source.boardNotice] : []),
|
|
296
369
|
],
|
|
297
370
|
watch: reading?.health,
|
|
371
|
+
...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
|
|
298
372
|
language: settings.responseLanguage,
|
|
299
373
|
}
|
|
300
374
|
}
|
package/src/plans/sprint-html.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
SprintPage,
|
|
16
16
|
} from './sprint-page'
|
|
17
17
|
import { phaseCountOf, phaseStateOf, statusSymbol, statusWord } from './symbols'
|
|
18
|
-
import { laneMarkText, TEXTS } from './texts'
|
|
18
|
+
import { checksText, laneMarkText, TEXTS } from './texts'
|
|
19
19
|
|
|
20
20
|
const BAR_PERCENT = 100
|
|
21
21
|
|
|
@@ -149,7 +149,7 @@ const pullRequestLine = (lane: PageLane): string =>
|
|
|
149
149
|
lane.pullRequests
|
|
150
150
|
.map(
|
|
151
151
|
(pull) =>
|
|
152
|
-
`<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(
|
|
152
|
+
`<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(checksText(pull))}</span> ${escapeHtml(pull.draft ? TEXTS.draft : '')}${escapeHtml(pull.title)}</div>`,
|
|
153
153
|
)
|
|
154
154
|
.join('')
|
|
155
155
|
|
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 { HERD_COMMAND } from './herd-command'
|
|
7
8
|
import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
|
|
8
9
|
import { TERMINAL_OFF } from './view'
|
|
9
10
|
|
|
@@ -13,7 +14,7 @@ const packageSourceDirectory = (): string => dirname(dirname(fileURLToPath(impor
|
|
|
13
14
|
|
|
14
15
|
const entryScript = (): string => {
|
|
15
16
|
const script = process.argv[1]
|
|
16
|
-
if (!script) throw new Error('Cannot supervise the
|
|
17
|
+
if (!script) throw new Error('Cannot supervise the herd: process.argv[1] is missing')
|
|
17
18
|
return script
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -21,7 +22,7 @@ const describeEnd = (code: number | null, signal: NodeJS.Signals | null): string
|
|
|
21
22
|
signal ? `signal ${signal}` : `exit ${code}`
|
|
22
23
|
|
|
23
24
|
const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
|
|
24
|
-
spawn(process.execPath, [scriptPath,
|
|
25
|
+
spawn(process.execPath, [scriptPath, HERD_COMMAND.name, ...args], {
|
|
25
26
|
stdio: 'inherit',
|
|
26
27
|
env: { ...process.env, [CHILD_MARKER]: '1' },
|
|
27
28
|
})
|
|
@@ -59,7 +60,7 @@ export const superviseView = (
|
|
|
59
60
|
const waitForChange = (reason: string): void => {
|
|
60
61
|
child = undefined
|
|
61
62
|
process.stdout.write(TERMINAL_OFF)
|
|
62
|
-
console.log(`
|
|
63
|
+
console.log(`herd stopped (${reason}); waiting for the next change to src/`)
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
const start = (isFirst: boolean): void => {
|
|
@@ -69,7 +70,7 @@ export const superviseView = (
|
|
|
69
70
|
current.on('error', (error) => {
|
|
70
71
|
if (child !== current) return
|
|
71
72
|
if (isFirst) {
|
|
72
|
-
console.error(`
|
|
73
|
+
console.error(`herd could not start: ${error.message}`)
|
|
73
74
|
finish(1)
|
|
74
75
|
return
|
|
75
76
|
}
|
package/src/plans/symbols.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
|
|
2
2
|
|
|
3
|
-
/** The symbols and words the
|
|
3
|
+
/** The symbols and words the herd panes share: herdr's own status words, and the state of a phase. Every lane state is one terminal cell; the layout colours it. */
|
|
4
4
|
export const STATUS_SYMBOL: Record<AgentStatus, string> = {
|
|
5
|
-
[AGENT_STATUS.working]: '
|
|
6
|
-
[AGENT_STATUS.blocked]: '
|
|
7
|
-
[AGENT_STATUS.idle]: '
|
|
8
|
-
[AGENT_STATUS.done]: '
|
|
5
|
+
[AGENT_STATUS.working]: '●',
|
|
6
|
+
[AGENT_STATUS.blocked]: '◆',
|
|
7
|
+
[AGENT_STATUS.idle]: '○',
|
|
8
|
+
[AGENT_STATUS.done]: '◉',
|
|
9
9
|
}
|
|
10
10
|
export const NO_AGENT_SYMBOL = '·'
|
|
11
11
|
export const NO_AGENT_WORD = 'no agent'
|
|
@@ -18,7 +18,7 @@ export const statusSymbol = (agent: string | null): string => {
|
|
|
18
18
|
|
|
19
19
|
export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WORD
|
|
20
20
|
|
|
21
|
-
/** Before a row that opens and closes: lanes, the
|
|
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
24
|
export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
|
|
@@ -45,6 +45,9 @@ export const phaseStateOf = ({
|
|
|
45
45
|
/** What a phase shows in place of a symbol: `0/5`, `✓ 15/15`, `–` without checkboxes, `?` when the plan lacks it. */
|
|
46
46
|
export const PHASE_COUNT = { missing: '?', empty: '–', allTicked: '✓' } as const
|
|
47
47
|
|
|
48
|
+
/** Beside the model a phase's subagent ran on: it fits the plan's recommendation, or it differs from it. */
|
|
49
|
+
export const MODEL_MARK = { fits: '✓', differs: '≠' } as const
|
|
50
|
+
|
|
48
51
|
export const phaseCountOf = (phase: { title: string | null; done: number; total: number }): string => {
|
|
49
52
|
const state = phaseStateOf(phase)
|
|
50
53
|
if (state === PHASE_STATE.missing) return PHASE_COUNT.missing
|
package/src/plans/texts.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { CHECKS, type ChecksState } from './pull-requests'
|
|
3
|
-
import {
|
|
2
|
+
import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
|
|
3
|
+
import { shownWithMore } from './shown-with-more'
|
|
4
|
+
import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Everything the
|
|
7
|
+
* Everything the herd says in words but the watcher's badges. The herd is English whatever language the
|
|
7
8
|
* project answers in; plan and phase titles come from the plans as written, and the status words are herdr's own terms.
|
|
8
9
|
*/
|
|
9
10
|
export type PlanTexts = {
|
|
@@ -15,11 +16,10 @@ export type PlanTexts = {
|
|
|
15
16
|
noRow: string
|
|
16
17
|
notInPlan: string
|
|
17
18
|
noCheckboxes: string
|
|
18
|
-
|
|
19
|
+
/** In place of the count of a phase whose only open box is the documentation box of its review gate. */
|
|
20
|
+
allButDocs: string
|
|
19
21
|
tickedUnmerged: string
|
|
20
22
|
tickedSummary: (count: number) => string
|
|
21
|
-
notesHidden: (count: number) => string
|
|
22
|
-
notesShown: (count: number) => string
|
|
23
23
|
draft: string
|
|
24
24
|
privateLink: string
|
|
25
25
|
planned: (phases: number, plans: number) => string
|
|
@@ -59,7 +59,7 @@ export const BADGE_TEXTS: Record<PlanLanguage, BadgeTexts> = {
|
|
|
59
59
|
},
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** What the
|
|
62
|
+
/** What the herd says about a board, in the word the project calls it (`board.term`). */
|
|
63
63
|
export const boardTexts = (term: string) => ({
|
|
64
64
|
missing: (glob: string) => `no ${term} under ${glob}`,
|
|
65
65
|
laneNotListed: (lane: string) => `lane "${lane}" is not in the ${term}`,
|
|
@@ -70,6 +70,8 @@ export const boardTexts = (term: string) => ({
|
|
|
70
70
|
notReadable: (reason: string) => `${term} not readable: ${reason}`,
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
+
const ALL_BUT_DOCS = 'docs open'
|
|
74
|
+
|
|
73
75
|
export const TEXTS: PlanTexts = {
|
|
74
76
|
noAgent: NO_AGENT_WORD,
|
|
75
77
|
waiting: (lanes) => `waiting (${lanes})`,
|
|
@@ -79,11 +81,9 @@ export const TEXTS: PlanTexts = {
|
|
|
79
81
|
noRow: 'no row in lanes',
|
|
80
82
|
notInPlan: 'not in the plan',
|
|
81
83
|
noCheckboxes: 'no checkboxes',
|
|
82
|
-
|
|
84
|
+
allButDocs: ALL_BUT_DOCS,
|
|
83
85
|
tickedUnmerged: 'ticked, not merged',
|
|
84
86
|
tickedSummary: (count) => `${count} ticked · click or a lists them`,
|
|
85
|
-
notesHidden: (count) => `${count} ${count === 1 ? 'note' : 'notes'} hidden · click or n shows them`,
|
|
86
|
-
notesShown: (count) => `${count} ${count === 1 ? 'note' : 'notes'} shown · click or n hides them`,
|
|
87
87
|
draft: 'draft · ',
|
|
88
88
|
privateLink: 'private',
|
|
89
89
|
following: (groups) => `also open · ${groups}`,
|
|
@@ -95,13 +95,30 @@ export const TEXTS: PlanTexts = {
|
|
|
95
95
|
[CHECKS.pending]: '… checks',
|
|
96
96
|
[CHECKS.none]: 'no checks',
|
|
97
97
|
},
|
|
98
|
-
hint: '↑↓ select →← open a
|
|
98
|
+
hint: '↑↓ select →← open a show ticked s html q quit ? help',
|
|
99
99
|
legend: [
|
|
100
100
|
'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 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
|
|
101
|
-
'
|
|
102
|
-
|
|
101
|
+
'● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · 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 · working and blocked lanes open by themselves, → ← overrule until the status changes',
|
|
102
|
+
`▸ ▾ open and close · ▶ next open task · ✓ finished phase · ${ALL_BUT_DOCS} = only the documentation box is open · ticked, not merged = all ticked in the worktree, not on the base branch yet · the herd follows the phase whose boxes moved last, in yellow · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
|
|
103
|
+
`◐ lane progress · n/m = m checkboxes of the phase, n ticked · n/n in green = all ticked · 🥣 share of the context window used · Opus ${MODEL_MARK.fits} = the phase's subagent ran on the plan's model, Sonnet ${MODEL_MARK.differs} Opus in amber = on another`,
|
|
104
|
+
'↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status under 📎, 📡 <n> in amber on a folded lane when tickets are new',
|
|
105
|
+
'⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
|
|
103
106
|
],
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
/** The mark of a running lane the board has no row for; null for a lane it lists. */
|
|
107
110
|
export const laneMarkText = (lane: { unlisted?: boolean }): string | null => (lane.unlisted ? TEXTS.noRow : null)
|
|
111
|
+
|
|
112
|
+
/** The most red checks the checks column names; the rest is counted. */
|
|
113
|
+
export const NAMED_CHECKS = 3
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The words of a pull request's check state: `✗ test, lint` names the red checks (at most `NAMED_CHECKS`, then
|
|
117
|
+
* `+<n>`), every other state and a red one without names is the fixed word of `PLAN_TEXTS.checks`.
|
|
118
|
+
*/
|
|
119
|
+
export const checksText = ({ checks, failedChecks }: Pick<PullRequest, 'checks' | 'failedChecks'>): string => {
|
|
120
|
+
if (checks !== CHECKS.failed || failedChecks.length === 0) return TEXTS.checks[checks]
|
|
121
|
+
const { shown, more } = shownWithMore(failedChecks, NAMED_CHECKS)
|
|
122
|
+
const names = shown.map((name) => name.slice(0, CHECK_NAME_LENGTH)).join(', ')
|
|
123
|
+
return `✗ ${names}${more > 0 ? ` +${more}` : ''}`
|
|
124
|
+
}
|
package/src/plans/view.ts
CHANGED
package/src/sync/herdr-plugin.ts
CHANGED
|
@@ -142,7 +142,7 @@ export const linkHerdrPlugin = ({
|
|
|
142
142
|
return `Could not link the herdr plugin, herdr said: ${result.output || `exit ${result.exitCode}`}`
|
|
143
143
|
const linkedLine = linked
|
|
144
144
|
? `Relinked the herdr plugin from ${linked.root} to ${directory} (${reasonText(reason, linked)}).`
|
|
145
|
-
: 'Linked the herdr plugin (the pane and the action for the
|
|
145
|
+
: 'Linked the herdr plugin (the pane and the action for the herd).'
|
|
146
146
|
return joinLines(linkedLine, unlink())
|
|
147
147
|
}
|
|
148
148
|
|
package/src/sync/next-steps.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const nextSteps = ({ skillPrefix, adapter, tools, version, cli }: NextSte
|
|
|
36
36
|
`${orchestrate}: run a board's phases in parallel lanes.`,
|
|
37
37
|
herdrLine(orchestrate, adapter, tools),
|
|
38
38
|
ghLine(orchestrate, tools),
|
|
39
|
-
`${cli}
|
|
39
|
+
`${cli} herd: the whole herd at a glance, the state of every lane.`,
|
|
40
40
|
`Docs: https://www.npmjs.com/package/${PACKAGE_NAME}/v/${version}#documentation`,
|
|
41
41
|
]
|
|
42
42
|
}
|
package/src/sync/render.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JackherdConfig } from '../config'
|
|
2
2
|
import { frozenInstallCommand } from '../config/detect'
|
|
3
|
-
import { ADDRESS_FORM, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
|
|
3
|
+
import { ADDRESS_FORM, APPROVAL_TEXTS, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
|
|
4
4
|
|
|
5
5
|
const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
|
|
6
6
|
|
|
@@ -39,6 +39,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
39
39
|
...prefixed('board', config.board),
|
|
40
40
|
['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
|
|
41
41
|
['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
|
|
42
|
+
...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
|
|
42
43
|
['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
|
|
43
44
|
...prefixed('heading', { ...header, ...sections, ...columns, phase }),
|
|
44
45
|
])
|
|
@@ -11,7 +11,7 @@ const LANGUAGE_CHOICES: readonly Choice<PlanLanguage>[] = [PLAN_LANGUAGE.en, PLA
|
|
|
11
11
|
}))
|
|
12
12
|
|
|
13
13
|
const ADAPTER_CHOICES: readonly Choice<TerminalAdapter>[] = [
|
|
14
|
-
{ value: TERMINAL_ADAPTER.herdr, label: 'Yes, use herdr (agent states and context fill in the
|
|
14
|
+
{ value: TERMINAL_ADAPTER.herdr, label: 'Yes, use herdr (agent states and context fill in the herd)' },
|
|
15
15
|
{ value: TERMINAL_ADAPTER.none, label: 'No, not now' },
|
|
16
16
|
]
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ const RESPONSE_QUESTION = 'Which language should the skills answer you in?'
|
|
|
20
20
|
const PLAN_QUESTION = 'Which language should plans and boards be written in? It sets their headings.'
|
|
21
21
|
|
|
22
22
|
const ADAPTER_QUESTION =
|
|
23
|
-
'Use herdr? It runs parallel lanes for /orchestrate and shows each agent in the
|
|
23
|
+
'Use herdr? It runs parallel lanes for /orchestrate and shows each agent in the herd; /plan and /implement work without it.'
|
|
24
24
|
|
|
25
25
|
const INSTALL_QUESTION = `herdr is not installed. It is only needed for parallel lanes. Install it now with its official installer, \`${INSTALLERS.herdr}\`?`
|
|
26
26
|
|
package/src/terminal/text.ts
CHANGED
|
@@ -41,6 +41,7 @@ export const paint = {
|
|
|
41
41
|
green: style('32'),
|
|
42
42
|
yellow: style('33'),
|
|
43
43
|
magenta: style('35'),
|
|
44
|
+
teal: style('38;5;37'),
|
|
44
45
|
cyan: style('36'),
|
|
45
46
|
inverse: style('7'),
|
|
46
47
|
}
|
|
@@ -67,6 +68,9 @@ export const link = (url: string, text: string): string =>
|
|
|
67
68
|
export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BADGE_TONE.grey): string =>
|
|
68
69
|
`${ESC}[${tone}m ${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''} ${RESET}`
|
|
69
70
|
|
|
71
|
+
/** `text` as a badge without a link, for what no click opens: the background of `tone`, a cell of padding either side. */
|
|
72
|
+
export const box = (text: string, tone: BadgeTone = BADGE_TONE.grey): string => `${ESC}[${tone}m ${text} ${RESET}`
|
|
73
|
+
|
|
70
74
|
/** A line for output that is not a terminal: styling dropped, each link written out as `text (url)`. */
|
|
71
75
|
export const plainText = (text: string): string =>
|
|
72
76
|
stripAnsi(text.replace(LINK, (_, url, inner) => `${stripAnsi(inner)} (${url})`))
|
package/src/terminal/types.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type AdoptedName = { pane: string; name: string }
|
|
|
16
16
|
export type LaneAgent = { name: string; pane: string; status: string; session: string | null; cwd: string | null }
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Where lane agents run. The
|
|
19
|
+
* Where lane agents run. The herd reads agent status and sets a short title per lane through
|
|
20
20
|
* this; the adapter `none` stands for a setup without a terminal multiplexer.
|
|
21
21
|
*/
|
|
22
22
|
export type LaneStatusSource = {
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -38,6 +38,25 @@ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
|
|
|
38
38
|
[PLAN_LANGUAGE.de]: 'Freigeben, und nach grünen Checks selbst mergen',
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The words of an approval: the two option labels of the coordinator's question about a lane's call, which the
|
|
43
|
+
* approval gate hook matches exactly, and what the hook tells the lane when it denies. Fixed per language, not config.
|
|
44
|
+
*/
|
|
45
|
+
export const APPROVAL_TEXTS: Record<PlanLanguage, { allow: string; deny: string; pending: string; denied: string }> = {
|
|
46
|
+
[PLAN_LANGUAGE.en]: {
|
|
47
|
+
allow: 'Allow this call',
|
|
48
|
+
deny: 'Deny this call',
|
|
49
|
+
pending: 'Approval pending with the coordinator; retry the same call once it is approved.',
|
|
50
|
+
denied: 'The user denied this call through the coordinator; do not retry it.',
|
|
51
|
+
},
|
|
52
|
+
[PLAN_LANGUAGE.de]: {
|
|
53
|
+
allow: 'Aufruf erlauben',
|
|
54
|
+
deny: 'Aufruf ablehnen',
|
|
55
|
+
pending: 'Freigabe steht aus beim Koordinator; denselben Aufruf nach der Freigabe erneut versuchen.',
|
|
56
|
+
denied: 'Der User hat diesen Aufruf über den Koordinator abgelehnt; nicht erneut versuchen.',
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
export const isPlanLanguage = (value: string): value is PlanLanguage =>
|
|
42
61
|
(Object.values(PLAN_LANGUAGE) as string[]).includes(value)
|
|
43
62
|
|
|
@@ -81,8 +100,18 @@ export type PlanVocabulary = {
|
|
|
81
100
|
laneClosed: string
|
|
82
101
|
/** Words a starts-after cell begins with when the lane may start at once. */
|
|
83
102
|
startsNow: readonly string[]
|
|
103
|
+
/** Opens the parenthesis by which a board row's phases cell records a user's side order in prose. */
|
|
104
|
+
sideOrderMarker: string
|
|
84
105
|
/** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
|
|
85
106
|
phaseConnectors: readonly string[]
|
|
107
|
+
/** Words that open a writes cell naming another phase's set instead of listing it (`wie 15`). */
|
|
108
|
+
writesLike: readonly string[]
|
|
109
|
+
/** Words that join several phases after a `writesLike` word (`wie 15 und 16`); a comma joins them in every language. */
|
|
110
|
+
writesLikeJoiners: readonly string[]
|
|
111
|
+
/** Words a `Depends on` cell says it depends on nothing with, besides a dash. */
|
|
112
|
+
noDependency: readonly string[]
|
|
113
|
+
/** The word that opens the serial marker of a phase that ships in a release (`Release 1.7.0`); the release files stay a judgement. */
|
|
114
|
+
releaseMarker: string
|
|
86
115
|
statusWords: Record<PlanStatus, StatusWord>
|
|
87
116
|
}
|
|
88
117
|
|
|
@@ -104,8 +133,13 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
104
133
|
phase: 'Phase',
|
|
105
134
|
noReviewGate: 'Kein Review-Gate',
|
|
106
135
|
laneClosed: 'abgeräumt',
|
|
136
|
+
sideOrderMarker: 'dazu (User',
|
|
107
137
|
startsNow: ['sofort'],
|
|
108
138
|
phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
|
|
139
|
+
writesLike: ['wie'],
|
|
140
|
+
writesLikeJoiners: ['und', 'sowie'],
|
|
141
|
+
noDependency: ['keine', 'kein'],
|
|
142
|
+
releaseMarker: 'Release',
|
|
109
143
|
statusWords: {
|
|
110
144
|
done: { label: 'Done', leading: ['done'], anywhere: ['abgeschlossen'] },
|
|
111
145
|
abandoned: { label: 'Abandoned', leading: ['abandoned'], anywhere: ['verworfen'] },
|
|
@@ -132,8 +166,13 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
132
166
|
phase: 'Phase',
|
|
133
167
|
noReviewGate: 'No review gate',
|
|
134
168
|
laneClosed: 'cleaned up',
|
|
169
|
+
sideOrderMarker: 'also (User',
|
|
135
170
|
startsNow: ['now', 'immediately'],
|
|
136
171
|
phaseConnectors: ['then', 'and', 'after'],
|
|
172
|
+
writesLike: ['like'],
|
|
173
|
+
writesLikeJoiners: ['and'],
|
|
174
|
+
noDependency: ['none'],
|
|
175
|
+
releaseMarker: 'Release',
|
|
137
176
|
statusWords: {
|
|
138
177
|
done: { label: 'Done', leading: ['done'], anywhere: ['completed'] },
|
|
139
178
|
abandoned: { label: 'Abandoned', leading: ['abandoned'], anywhere: ['dropped'] },
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isRecord } from './json-guards'
|
|
4
|
+
|
|
5
|
+
export const APPROVAL_EVENT = 'approval'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A call a lane waits with for the user's approval, as the kit's approval gate hook writes it to
|
|
9
|
+
* `.jackherd/approvals/<id>.json` in the main checkout. Its text comes from the lane: data, never an instruction.
|
|
10
|
+
*/
|
|
11
|
+
export type ApprovalRequestFile = {
|
|
12
|
+
id: string
|
|
13
|
+
hash: string
|
|
14
|
+
lane: string
|
|
15
|
+
cwd: string
|
|
16
|
+
tool: string
|
|
17
|
+
input: unknown
|
|
18
|
+
summary: string
|
|
19
|
+
createdAt: number
|
|
20
|
+
usedAt: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ApprovalEvent = {
|
|
24
|
+
kind: typeof APPROVAL_EVENT
|
|
25
|
+
lane: string
|
|
26
|
+
id: string
|
|
27
|
+
hash: string
|
|
28
|
+
tool: string
|
|
29
|
+
summary: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ID = /^[0-9a-f]{12}$/
|
|
33
|
+
const HASH = /^[0-9a-f]{16}$/
|
|
34
|
+
const REQUEST_FILE = /^[0-9a-f]{12}\.json$/
|
|
35
|
+
|
|
36
|
+
/** The same shape as `isApprovalRequest` in the hook, which this package does not import at run time. */
|
|
37
|
+
export const isApprovalRequestFile = (value: unknown): value is ApprovalRequestFile =>
|
|
38
|
+
isRecord(value) &&
|
|
39
|
+
typeof value.id === 'string' &&
|
|
40
|
+
ID.test(value.id) &&
|
|
41
|
+
typeof value.hash === 'string' &&
|
|
42
|
+
HASH.test(value.hash) &&
|
|
43
|
+
typeof value.lane === 'string' &&
|
|
44
|
+
typeof value.cwd === 'string' &&
|
|
45
|
+
typeof value.tool === 'string' &&
|
|
46
|
+
'input' in value &&
|
|
47
|
+
typeof value.summary === 'string' &&
|
|
48
|
+
typeof value.createdAt === 'number' &&
|
|
49
|
+
(value.usedAt === null || typeof value.usedAt === 'number')
|
|
50
|
+
|
|
51
|
+
const REPORT_MINUTES = 30
|
|
52
|
+
const MS_PER_MINUTE = 60_000
|
|
53
|
+
|
|
54
|
+
/** One event per open request younger than 30 minutes the watcher has not reported yet; an older one is stale. */
|
|
55
|
+
export const approvalEvents = (
|
|
56
|
+
seen: readonly string[],
|
|
57
|
+
requests: readonly ApprovalRequestFile[],
|
|
58
|
+
now: number,
|
|
59
|
+
): ApprovalEvent[] =>
|
|
60
|
+
requests
|
|
61
|
+
.filter(
|
|
62
|
+
(request) =>
|
|
63
|
+
request.usedAt === null &&
|
|
64
|
+
!seen.includes(request.id) &&
|
|
65
|
+
now - request.createdAt <= REPORT_MINUTES * MS_PER_MINUTE,
|
|
66
|
+
)
|
|
67
|
+
.map(({ lane, id, hash, tool, summary }) => ({ kind: APPROVAL_EVENT, lane, id, hash, tool, summary }))
|
|
68
|
+
|
|
69
|
+
/** The ids to remember: every request still on disk, so one that is gone is forgotten. */
|
|
70
|
+
export const recordApprovals = (requests: readonly ApprovalRequestFile[]): string[] =>
|
|
71
|
+
requests.map((request) => request.id)
|
|
72
|
+
|
|
73
|
+
const requestFileNames = (directory: string): string[] => {
|
|
74
|
+
try {
|
|
75
|
+
return readdirSync(directory).filter((name) => REQUEST_FILE.test(name))
|
|
76
|
+
} catch {
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The requests in the main checkout's `.jackherd/approvals/`, a claimed one as used; a file that does not parse is
|
|
83
|
+
* skipped, and a directory that cannot be listed reads as none, so the watch goes on.
|
|
84
|
+
*/
|
|
85
|
+
export const readApprovalRequests = (root: string): ApprovalRequestFile[] => {
|
|
86
|
+
const directory = join(root, '.jackherd', 'approvals')
|
|
87
|
+
return requestFileNames(directory).flatMap((name) => {
|
|
88
|
+
try {
|
|
89
|
+
const request: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
|
|
90
|
+
if (!isApprovalRequestFile(request) || `${request.id}.json` !== name) return []
|
|
91
|
+
return [existsSync(join(directory, `${request.id}.used`)) ? { ...request, usedAt: request.createdAt } : request]
|
|
92
|
+
} catch {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|