jackherd 0.0.1 → 1.0.1
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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- 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 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { resolvePlanPath } from '../plan-format'
|
|
3
|
+
import type { LinkChip } from './link-chips'
|
|
4
|
+
import type { Snapshot } from './snapshot'
|
|
5
|
+
import type { WrittenPage } from './sprint-export'
|
|
6
|
+
|
|
7
|
+
const PLAN_CHIP = { label: 'Plan', icon: '📄' } as const
|
|
8
|
+
|
|
9
|
+
/** The chip that opens a plan's section in the sprint page. */
|
|
10
|
+
export const planChip = (pagePath: string, anchor: string): LinkChip => ({
|
|
11
|
+
url: `${pathToFileURL(pagePath).href}#${anchor}`,
|
|
12
|
+
label: PLAN_CHIP.label,
|
|
13
|
+
icon: PLAN_CHIP.icon,
|
|
14
|
+
private: false,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The snapshot with a plan chip in front of the links of every plan the page carries. A plan the page does not
|
|
19
|
+
* carry gets none.
|
|
20
|
+
*/
|
|
21
|
+
export const withPlanChips = (snapshot: Snapshot, page: WrittenPage, sprintDirectory: string): Snapshot => ({
|
|
22
|
+
...snapshot,
|
|
23
|
+
views: snapshot.views.map((view) => ({
|
|
24
|
+
...view,
|
|
25
|
+
chips: Object.fromEntries(
|
|
26
|
+
Object.entries(view.chips).map(([href, chips]) => {
|
|
27
|
+
const anchor = page.anchors.get(resolvePlanPath(sprintDirectory, href))
|
|
28
|
+
return [href, anchor ? [planChip(page.path, anchor), ...chips] : chips]
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
})),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The anchor of a plan chip's address when it points into the page at `pagePath`; null for any other address,
|
|
36
|
+
* so a `file:` address that is not the page is never opened.
|
|
37
|
+
*/
|
|
38
|
+
export const planAnchorOn = (pagePath: string | null, address: string): string | null => {
|
|
39
|
+
if (!pagePath) return null
|
|
40
|
+
try {
|
|
41
|
+
const requested = new URL(address)
|
|
42
|
+
const page = pathToFileURL(pagePath)
|
|
43
|
+
return requested.protocol === page.protocol && requested.pathname === page.pathname
|
|
44
|
+
? requested.hash.slice(1) || null
|
|
45
|
+
: null
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What decides whether the written page is out of date: the sprint text, the plan texts and the planned phases.
|
|
53
|
+
* The status of the lanes is left out on purpose, it changes every few seconds and building the page costs
|
|
54
|
+
* seconds; a page older than that says so in its header, and the key `s` writes a fresh one.
|
|
55
|
+
*/
|
|
56
|
+
export const pageKey = ({ documents, planned }: Snapshot): string =>
|
|
57
|
+
Bun.hash(JSON.stringify([documents, planned])).toString(36)
|
|
58
|
+
|
|
59
|
+
/** The sprint page last written, with the key of the plans it was built from. */
|
|
60
|
+
export type KnownPage = WrittenPage & { key: string }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the page has to be written: none yet, built from other plans, or the file is gone. In the view of one
|
|
64
|
+
* lane the key comes from the plans that view reads, so a plan only another lane names is not noticed there;
|
|
65
|
+
* the key `s` writes the whole sprint.
|
|
66
|
+
*/
|
|
67
|
+
export const pageNeedsWriting = (page: KnownPage | null, key: string, fileExists: boolean): boolean =>
|
|
68
|
+
page === null || page.key !== key || !fileExists
|
|
69
|
+
|
|
70
|
+
/** The page to remember after a write: the new one, else the last good one, whose chips stay. */
|
|
71
|
+
export const pageAfterWrite = (
|
|
72
|
+
previous: KnownPage | null,
|
|
73
|
+
written: WrittenPage | { error: string },
|
|
74
|
+
key: string,
|
|
75
|
+
): KnownPage | null => ('error' in written ? previous : { ...written, key })
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** One herdr pane as `plans --ensure` sees it: where it is, whether an agent runs in it, its foreground commands. */
|
|
4
|
+
export type PaneState = { pane: string; tab: string; cwd: string; agent: boolean; foreground: string[][] }
|
|
5
|
+
|
|
6
|
+
/** Runs a command in a free shell pane, or splits a pane to the right and runs it in the new one. */
|
|
7
|
+
export type PaneAction = { kind: 'run' | 'split'; pane: string; command: string }
|
|
8
|
+
|
|
9
|
+
export type PlanPaneInput = {
|
|
10
|
+
panes: readonly PaneState[]
|
|
11
|
+
/** The coordinator's pane, null when none was found. */
|
|
12
|
+
coordinator: string | null
|
|
13
|
+
lanes: readonly { name: string; cwd: string }[]
|
|
14
|
+
root: string
|
|
15
|
+
/** The worktree directory under `root`: a shell there belongs to a lane, not to the coordinator. */
|
|
16
|
+
worktrees: string
|
|
17
|
+
/** The line that says why there is no coordinator pane. */
|
|
18
|
+
missingCoordinator: string
|
|
19
|
+
/** The command that starts the view: of the lane, or of every lane for null. */
|
|
20
|
+
command: (lane: string | null) => string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PlanPaneResult = { actions: PaneAction[]; notes: string[] }
|
|
24
|
+
|
|
25
|
+
type ListedPane = { pane_id: string; tab_id: string; cwd: string; agent?: unknown }
|
|
26
|
+
|
|
27
|
+
const isListedPane = (value: unknown): value is ListedPane => {
|
|
28
|
+
if (typeof value !== 'object' || value === null) return false
|
|
29
|
+
const candidate = value as Record<string, unknown>
|
|
30
|
+
return (
|
|
31
|
+
typeof candidate.pane_id === 'string' && typeof candidate.tab_id === 'string' && typeof candidate.cwd === 'string'
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The panes of a `herdr pane list` answer, without their foreground; throws on a changed answer. */
|
|
36
|
+
export const paneListOf = (json: string): Omit<PaneState, 'foreground'>[] => {
|
|
37
|
+
const panes = (JSON.parse(json) as { result?: { panes?: unknown } }).result?.panes
|
|
38
|
+
if (!Array.isArray(panes) || !panes.every(isListedPane)) throw new Error('herdr pane list: unexpected answer')
|
|
39
|
+
return panes.map((each) => ({
|
|
40
|
+
pane: each.pane_id,
|
|
41
|
+
tab: each.tab_id,
|
|
42
|
+
cwd: each.cwd,
|
|
43
|
+
agent: typeof each.agent === 'string',
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pane of every agent in a `herdr agent list` answer, named or not; throws on a changed answer. */
|
|
48
|
+
export const agentPanesOf = (json: string): string[] => {
|
|
49
|
+
const agents = (JSON.parse(json) as { result?: { agents?: unknown } }).result?.agents
|
|
50
|
+
if (!Array.isArray(agents)) throw new Error('herdr agent list: unexpected answer')
|
|
51
|
+
return agents.flatMap((agent) => {
|
|
52
|
+
const pane = (agent as { pane_id?: unknown } | null)?.pane_id
|
|
53
|
+
return typeof pane === 'string' ? [pane] : []
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Marks the panes herdr lists an agent for. After a restart `herdr pane list` names no agent in a pane for a few
|
|
59
|
+
* seconds, until the agent's process is back, while `herdr agent list` keeps it: such a pane is never a free shell.
|
|
60
|
+
*/
|
|
61
|
+
export const withAgentPanes = <Pane extends { pane: string; agent: boolean }>(
|
|
62
|
+
panes: readonly Pane[],
|
|
63
|
+
agentPanes: readonly string[],
|
|
64
|
+
): Pane[] => panes.map((each) => (agentPanes.includes(each.pane) ? { ...each, agent: true } : each))
|
|
65
|
+
|
|
66
|
+
const isArgv = (value: unknown): value is string[] =>
|
|
67
|
+
Array.isArray(value) && value.every((arg) => typeof arg === 'string')
|
|
68
|
+
|
|
69
|
+
/** The argv of each foreground process in a `herdr pane process-info` answer; none when it names none. */
|
|
70
|
+
export const foregroundOf = (json: string): string[][] => {
|
|
71
|
+
const processes = (JSON.parse(json) as { result?: { process_info?: { foreground_processes?: unknown } } }).result
|
|
72
|
+
?.process_info?.foreground_processes
|
|
73
|
+
if (!Array.isArray(processes)) return []
|
|
74
|
+
return processes.flatMap((each) => {
|
|
75
|
+
const argv = (each as { argv?: unknown } | null)?.argv
|
|
76
|
+
return isArgv(argv) ? [argv] : []
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SHELLS = new Set(['bash', 'zsh', 'sh', 'fish', 'dash', 'ksh'])
|
|
81
|
+
|
|
82
|
+
/** Arguments that make a `plans` call something other than the live view. */
|
|
83
|
+
const NOT_A_VIEW = ['--once', '--ensure']
|
|
84
|
+
|
|
85
|
+
const programOf = (argv: readonly string[]): string => basename(argv[0] ?? '').replace(/^-/, '')
|
|
86
|
+
|
|
87
|
+
const isKitEntry = (arg: string): boolean => /(jh|jackherd)(@[^/]+)?$/.test(arg) || arg.endsWith('cli.ts')
|
|
88
|
+
|
|
89
|
+
const laneOf = (argv: readonly string[]): string | null => {
|
|
90
|
+
const index = argv.indexOf('--lane')
|
|
91
|
+
return index === -1 ? null : (argv[index + 1] ?? null)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isViewCall = (argv: readonly string[], lane: string | null): boolean =>
|
|
95
|
+
argv.includes('plans') &&
|
|
96
|
+
argv.some(isKitEntry) &&
|
|
97
|
+
!argv.some((arg) => NOT_A_VIEW.includes(arg)) &&
|
|
98
|
+
laneOf(argv) === lane
|
|
99
|
+
|
|
100
|
+
/** Whether a foreground command is the live plans view: of `lane`, or of every lane for null. */
|
|
101
|
+
export const isPlanView = (foreground: readonly (readonly string[])[], lane: string | null): boolean =>
|
|
102
|
+
foreground.some((argv) => isViewCall(argv, lane))
|
|
103
|
+
|
|
104
|
+
/** A pane with nothing but a shell in the foreground: what a restored pane whose command is gone looks like. */
|
|
105
|
+
export const isIdleShell = ({ agent, foreground }: PaneState): boolean =>
|
|
106
|
+
!agent && foreground.length > 0 && foreground.every((argv) => SHELLS.has(programOf(argv)))
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The coordinator's pane: the agent herdr lists under the coordinator's name, else the calling pane when an agent
|
|
110
|
+
* in the main checkout runs there, else the only agent in the main checkout. Two agents there and no name: none.
|
|
111
|
+
*/
|
|
112
|
+
export const coordinatorPaneOf = (
|
|
113
|
+
panes: readonly PaneState[],
|
|
114
|
+
root: string,
|
|
115
|
+
hints: { named: string | null; caller: string | null },
|
|
116
|
+
): string | null => {
|
|
117
|
+
if (hints.named) return hints.named
|
|
118
|
+
const inRoot = panes.filter((each) => each.agent && each.cwd === root)
|
|
119
|
+
if (hints.caller && inRoot.some((each) => each.pane === hints.caller)) return hints.caller
|
|
120
|
+
return inRoot.length === 1 ? (inRoot[0]?.pane ?? null) : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Why no coordinator pane was found: no agent in the main checkout, or several and none of them named. */
|
|
124
|
+
export const missingCoordinatorNote = (panes: readonly PaneState[], root: string, name: string): string => {
|
|
125
|
+
const inRoot = panes.filter((each) => each.agent && each.cwd === root).length
|
|
126
|
+
return inRoot === 0
|
|
127
|
+
? 'coordinator: no agent in the main checkout'
|
|
128
|
+
: `coordinator: ${inRoot} agents in the main checkout and none named ${name}; run plans --ensure in its pane, or herdr agent rename <pane> ${name}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`)
|
|
132
|
+
|
|
133
|
+
/** The shell command that starts the view with the CLI `argv` names (runtime and entry), for `lane` or every lane. */
|
|
134
|
+
export const viewCommandOf = (argv: readonly string[], lane: string | null): string =>
|
|
135
|
+
[...argv.slice(0, 2), 'plans', ...(lane ? ['--lane', lane] : [])].map(quoted).join(' ')
|
|
136
|
+
|
|
137
|
+
/** The new pane of a `herdr pane split` answer; throws on a changed answer. */
|
|
138
|
+
export const splitPaneOf = (json: string): string => {
|
|
139
|
+
const pane = (JSON.parse(json) as { result?: { pane?: { pane_id?: unknown } } }).result?.pane?.pane_id
|
|
140
|
+
if (typeof pane !== 'string') throw new Error('herdr pane split: unexpected answer')
|
|
141
|
+
return pane
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What one view needs in the tab of `anchor`: nothing when a pane there shows it, a run in a free shell whose
|
|
146
|
+
* directory `isHome` accepts (the pane the view ran in before a restart), else a split of the anchor pane.
|
|
147
|
+
*/
|
|
148
|
+
const actionFor = (
|
|
149
|
+
panes: readonly PaneState[],
|
|
150
|
+
anchor: PaneState,
|
|
151
|
+
isHome: (cwd: string) => boolean,
|
|
152
|
+
lane: string | null,
|
|
153
|
+
command: string,
|
|
154
|
+
): PaneAction | null => {
|
|
155
|
+
const tab = panes.filter((each) => each.tab === anchor.tab)
|
|
156
|
+
if (tab.some((each) => isPlanView(each.foreground, lane))) return null
|
|
157
|
+
const free = tab.find((each) => isHome(each.cwd) && isIdleShell(each))
|
|
158
|
+
return free ? { kind: 'run', pane: free.pane, command } : { kind: 'split', pane: anchor.pane, command }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A directory at or below the worktree: a shell there may have changed into a subdirectory. */
|
|
162
|
+
const insideOf =
|
|
163
|
+
(directory: string) =>
|
|
164
|
+
(cwd: string): boolean =>
|
|
165
|
+
cwd === directory || cwd.startsWith(`${directory}/`)
|
|
166
|
+
|
|
167
|
+
/** A directory of the main checkout outside the lanes' worktrees. */
|
|
168
|
+
const inMainCheckout = (root: string, worktrees: string) => {
|
|
169
|
+
const inRoot = insideOf(root)
|
|
170
|
+
const inWorktrees = insideOf(worktrees)
|
|
171
|
+
return (cwd: string): boolean => inRoot(cwd) && !inWorktrees(cwd)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The pane a lane's view belongs beside: its agent in the worktree. Without one there is none yet: a shell there is
|
|
176
|
+
* the pane the agent is about to be started in.
|
|
177
|
+
*/
|
|
178
|
+
const laneAnchor = (panes: readonly PaneState[], inside: (cwd: string) => boolean): PaneState | undefined =>
|
|
179
|
+
panes.find((each) => inside(each.cwd) && each.agent)
|
|
180
|
+
|
|
181
|
+
/** The plans views that are missing beside the coordinator and each lane, and how to bring each back. */
|
|
182
|
+
export const planPaneActions = ({
|
|
183
|
+
panes,
|
|
184
|
+
coordinator,
|
|
185
|
+
lanes,
|
|
186
|
+
root,
|
|
187
|
+
worktrees,
|
|
188
|
+
command,
|
|
189
|
+
missingCoordinator,
|
|
190
|
+
}: PlanPaneInput): PlanPaneResult => {
|
|
191
|
+
const actions: PaneAction[] = []
|
|
192
|
+
const notes: string[] = []
|
|
193
|
+
const coordinatorPane = panes.find((each) => each.pane === coordinator)
|
|
194
|
+
const coordinatorAction =
|
|
195
|
+
coordinatorPane && actionFor(panes, coordinatorPane, inMainCheckout(root, worktrees), null, command(null))
|
|
196
|
+
if (!coordinatorPane) notes.push(missingCoordinator)
|
|
197
|
+
if (coordinatorAction) actions.push(coordinatorAction)
|
|
198
|
+
for (const lane of lanes) {
|
|
199
|
+
const inside = insideOf(lane.cwd)
|
|
200
|
+
const anchor = laneAnchor(panes, inside)
|
|
201
|
+
const action = anchor && actionFor(panes, anchor, inside, lane.name, command(lane.name))
|
|
202
|
+
if (!anchor) notes.push(`${lane.name}: waiting for its agent`)
|
|
203
|
+
if (action) actions.push(action)
|
|
204
|
+
}
|
|
205
|
+
return { actions, notes }
|
|
206
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
import {
|
|
3
|
+
PHASE_ID_PREFIX,
|
|
4
|
+
type PlanPhase,
|
|
5
|
+
parsePlanPhases,
|
|
6
|
+
readPlanHeader,
|
|
7
|
+
resolvePlanPath,
|
|
8
|
+
type SprintLane,
|
|
9
|
+
} from '../plan-format'
|
|
10
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
11
|
+
|
|
12
|
+
/** The open phases of one plan that no lane names: the plan's section, its phases, their checkboxes. */
|
|
13
|
+
export type PlannedPlan = {
|
|
14
|
+
/** The plan's file in the repository, which is what tells two spellings of one link apart. */
|
|
15
|
+
path: string
|
|
16
|
+
title: string
|
|
17
|
+
/** Ids without the `P`, in the order of the plan. */
|
|
18
|
+
phaseIds: string[]
|
|
19
|
+
done: number
|
|
20
|
+
total: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A lane of the sprint and what its agent reports, null when it has none. */
|
|
24
|
+
export type PlannedLane = { lane: SprintLane; agent: string | null }
|
|
25
|
+
|
|
26
|
+
export type PlannedInput = {
|
|
27
|
+
/** Plan texts by the link as the sprint writes it, in the order the sprint names them. */
|
|
28
|
+
plans: Readonly<Record<string, string>>
|
|
29
|
+
lanes: readonly PlannedLane[]
|
|
30
|
+
sprintDirectory: string
|
|
31
|
+
language: PlanLanguage
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A phase is open while a task before its review gate is unticked; an open documentation or roadmap box in
|
|
36
|
+
* the gate does not reopen a phase that is built and merged. A phase that has only gate boxes counts by them.
|
|
37
|
+
*/
|
|
38
|
+
const isOpen = ({ items }: PlanPhase): boolean => {
|
|
39
|
+
const tasks = items.filter(({ gate }) => !gate)
|
|
40
|
+
return (tasks.length > 0 ? tasks : items).some(({ done }) => !done)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const openPhases = (phases: PlanPhase[]): PlanPhase[] => phases.filter(isOpen)
|
|
44
|
+
|
|
45
|
+
const isRunning = ({ lane, agent }: PlannedLane): boolean => agent !== null && !lane.closed
|
|
46
|
+
|
|
47
|
+
const planPaths = ({ lane }: PlannedLane, sprintDirectory: string): string[] =>
|
|
48
|
+
lane.steps.flatMap(({ planHref }) => (planHref ? [resolvePlanPath(sprintDirectory, planHref)] : []))
|
|
49
|
+
|
|
50
|
+
/** The plans a running lane works on: their open phases show under that lane, so the planned row leaves them out. */
|
|
51
|
+
const followedPaths = ({ lanes, sprintDirectory }: PlannedInput): Set<string> =>
|
|
52
|
+
new Set(lanes.filter(isRunning).flatMap((lane) => planPaths(lane, sprintDirectory)))
|
|
53
|
+
|
|
54
|
+
const plannedPlanOf = (path: string, markdown: string, language: PlanLanguage, excluded: ReadonlySet<string>) => {
|
|
55
|
+
const phases = openPhases(parsePlanPhases(markdown, language)).filter(({ id }) => !excluded.has(id))
|
|
56
|
+
if (phases.length === 0) return []
|
|
57
|
+
const items = phases.flatMap((phase) => phase.items)
|
|
58
|
+
const plan: PlannedPlan = {
|
|
59
|
+
path,
|
|
60
|
+
title: readPlanHeader(markdown, language, posix.basename(path, '.md')).title,
|
|
61
|
+
phaseIds: phases.map(({ id }) => id),
|
|
62
|
+
done: items.filter(({ done }) => done).length,
|
|
63
|
+
total: items.length,
|
|
64
|
+
}
|
|
65
|
+
return [plan]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The phases that are planned and open: each plan of the sprint (its plans tables and its lanes) with the phases
|
|
70
|
+
* that still have an unticked checkbox, less the plans a lane with an agent works on (those show under the lane).
|
|
71
|
+
* A plan appears once however the sprint spells its link, and not at all when nothing in it is open.
|
|
72
|
+
*/
|
|
73
|
+
export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
|
|
74
|
+
const followed = followedPaths(input)
|
|
75
|
+
const seen = new Set<string>()
|
|
76
|
+
return Object.entries(input.plans).flatMap(([href, markdown]) => {
|
|
77
|
+
const path = resolvePlanPath(input.sprintDirectory, href)
|
|
78
|
+
if (seen.has(path) || followed.has(path)) return []
|
|
79
|
+
seen.add(path)
|
|
80
|
+
return plannedPlanOf(path, markdown, input.language, new Set())
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The open phases of the plans a lane works on that its own steps do not name, which is what comes after the
|
|
86
|
+
* phase the lane is on. Empty for a lane without an agent or a closed one: it is listed in the closing block.
|
|
87
|
+
*/
|
|
88
|
+
export const followingPlans = (planned: PlannedLane, input: Omit<PlannedInput, 'lanes'>): PlannedPlan[] => {
|
|
89
|
+
if (!isRunning(planned)) return []
|
|
90
|
+
const seen = new Set<string>()
|
|
91
|
+
return planned.lane.steps.flatMap(({ planHref }) => {
|
|
92
|
+
const markdown = planHref ? input.plans[planHref] : undefined
|
|
93
|
+
const path = planHref ? resolvePlanPath(input.sprintDirectory, planHref) : null
|
|
94
|
+
if (path === null || markdown === undefined || seen.has(path)) return []
|
|
95
|
+
seen.add(path)
|
|
96
|
+
const named = new Set(
|
|
97
|
+
planned.lane.steps
|
|
98
|
+
.filter((step) => step.planHref !== null && resolvePlanPath(input.sprintDirectory, step.planHref) === path)
|
|
99
|
+
.flatMap((step) => step.phaseIds),
|
|
100
|
+
)
|
|
101
|
+
return plannedPlanOf(path, markdown, input.language, named)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The plan the lane's current phase belongs to goes first; the others keep their order. */
|
|
106
|
+
export const currentPlanFirst = (plans: readonly PlannedPlan[], currentPath: string | null): PlannedPlan[] => [
|
|
107
|
+
...plans.filter(({ path }) => path === currentPath),
|
|
108
|
+
...plans.filter(({ path }) => path !== currentPath),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
/** `P3 P4 P5` */
|
|
112
|
+
export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string =>
|
|
113
|
+
plan.phaseIds.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' ')
|
|
114
|
+
|
|
115
|
+
/** How many phases the plans list together. */
|
|
116
|
+
export const plannedPhaseCount = (planned: readonly Pick<PlannedPlan, 'phaseIds'>[]): number =>
|
|
117
|
+
planned.reduce((count, plan) => count + plan.phaseIds.length, 0)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** The check state of a pull request, folded from all its check runs and commit statuses. */
|
|
4
|
+
export const CHECKS = { passed: 'passed', failed: 'failed', pending: 'pending', none: 'none' } as const
|
|
5
|
+
|
|
6
|
+
export type ChecksState = (typeof CHECKS)[keyof typeof CHECKS]
|
|
7
|
+
|
|
8
|
+
export type PullRequest = {
|
|
9
|
+
number: number
|
|
10
|
+
title: string
|
|
11
|
+
url: string
|
|
12
|
+
branch: string
|
|
13
|
+
draft: boolean
|
|
14
|
+
checks: ChecksState
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One entry of `statusCheckRollup`: a check run carries status and conclusion, a commit status a state. */
|
|
18
|
+
export type RollupEntry = { __typename?: string; status?: string; conclusion?: string | null; state?: string }
|
|
19
|
+
|
|
20
|
+
export type GhPullRequest = {
|
|
21
|
+
number: number
|
|
22
|
+
title: string
|
|
23
|
+
url: string
|
|
24
|
+
headRefName: string
|
|
25
|
+
isDraft: boolean
|
|
26
|
+
statusCheckRollup: RollupEntry[] | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The fields `parsePullRequests` reads; pass them to `gh pr list --json`. */
|
|
30
|
+
export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup'
|
|
31
|
+
|
|
32
|
+
const FAILED = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
|
|
33
|
+
const PENDING_STATES = new Set(['PENDING', 'EXPECTED'])
|
|
34
|
+
|
|
35
|
+
const isFailed = ({ conclusion, state }: RollupEntry): boolean =>
|
|
36
|
+
FAILED.has(conclusion ?? '') || FAILED.has(state ?? '')
|
|
37
|
+
|
|
38
|
+
const isPending = ({ status, state }: RollupEntry): boolean =>
|
|
39
|
+
state === undefined ? status !== 'COMPLETED' : PENDING_STATES.has(state)
|
|
40
|
+
|
|
41
|
+
export const checksOf = (rollup: readonly RollupEntry[]): ChecksState => {
|
|
42
|
+
if (rollup.length === 0) return CHECKS.none
|
|
43
|
+
if (rollup.some(isFailed)) return CHECKS.failed
|
|
44
|
+
return rollup.some(isPending) ? CHECKS.pending : CHECKS.passed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PULL_REQUEST_PATH = /^\/[\w.-]+\/[\w.-]+\/pull\/(\d+)$/
|
|
48
|
+
|
|
49
|
+
/** True only for `https://<host>/<owner>/<repo>/pull/<number>` of this very pull request: the view opens it on a click. */
|
|
50
|
+
const isPullRequestPage = (url: string, number: number, host: string): boolean => {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(url)
|
|
53
|
+
const path = parsed.pathname.match(PULL_REQUEST_PATH)
|
|
54
|
+
const exact = parsed.href === url && parsed.search === '' && parsed.hash === ''
|
|
55
|
+
return exact && parsed.protocol === 'https:' && parsed.hostname === host && Number(path?.[1]) === number
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One entry of `gh pr list --json <PULL_REQUEST_FIELDS>` whose link is the pull request's own page on `host`. */
|
|
62
|
+
export const isGhPullRequest = (value: unknown, host: string): value is GhPullRequest => {
|
|
63
|
+
if (typeof value !== 'object' || value === null) return false
|
|
64
|
+
const candidate = value as Record<string, unknown>
|
|
65
|
+
return (
|
|
66
|
+
typeof candidate.number === 'number' &&
|
|
67
|
+
typeof candidate.title === 'string' &&
|
|
68
|
+
typeof candidate.url === 'string' &&
|
|
69
|
+
isPullRequestPage(candidate.url, candidate.number, host) &&
|
|
70
|
+
typeof candidate.headRefName === 'string' &&
|
|
71
|
+
typeof candidate.isDraft === 'boolean' &&
|
|
72
|
+
(candidate.statusCheckRollup === null || Array.isArray(candidate.statusCheckRollup))
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads the JSON of `gh pr list --json <PULL_REQUEST_FIELDS>`. Throws on anything else, including a
|
|
78
|
+
* link that is not the pull request's own page on `host`: the view opens these links on a click.
|
|
79
|
+
* Titles and branch names come from whoever opened the pull request and are made terminal-safe here.
|
|
80
|
+
*/
|
|
81
|
+
export const parsePullRequests = (json: string, host = 'github.com'): PullRequest[] => {
|
|
82
|
+
const answer: unknown = JSON.parse(json)
|
|
83
|
+
const valid = Array.isArray(answer) && answer.every((entry) => isGhPullRequest(entry, host))
|
|
84
|
+
if (!valid) throw new Error('gh pr list: unexpected answer')
|
|
85
|
+
return (answer as GhPullRequest[]).map((pullRequest) => ({
|
|
86
|
+
number: pullRequest.number,
|
|
87
|
+
title: terminalSafe(pullRequest.title),
|
|
88
|
+
url: pullRequest.url,
|
|
89
|
+
branch: terminalSafe(pullRequest.headRefName),
|
|
90
|
+
draft: pullRequest.isDraft,
|
|
91
|
+
checks: checksOf(pullRequest.statusCheckRollup ?? []),
|
|
92
|
+
}))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const pullRequestsByBranch = (pullRequests: readonly PullRequest[]): Map<string, PullRequest[]> =>
|
|
96
|
+
pullRequests.reduce(
|
|
97
|
+
(byBranch, pullRequest) =>
|
|
98
|
+
byBranch.set(pullRequest.branch, [...(byBranch.get(pullRequest.branch) ?? []), pullRequest]),
|
|
99
|
+
new Map<string, PullRequest[]>(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/** The selection key of a pull request row; two parts, so it never equals a three-part phase key. */
|
|
103
|
+
export const pullRequestKey = (number: number): string => `pr:${number}`
|
|
104
|
+
|
|
105
|
+
/** The link behind a row key, or null when the key is a phase or a pull request no longer open. */
|
|
106
|
+
export const pullRequestUrl = (pullRequests: readonly PullRequest[], key: string): string | null =>
|
|
107
|
+
pullRequests.find((pullRequest) => pullRequestKey(pullRequest.number) === key)?.url ?? null
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { constants } from 'node:os'
|
|
2
|
+
|
|
3
|
+
export const CHILD_MARKER = 'JACKHERD_PLANS_CHILD'
|
|
4
|
+
|
|
5
|
+
/** Milliseconds a burst of file changes is collapsed into one restart. */
|
|
6
|
+
export const RESTART_DEBOUNCE_MS = 300
|
|
7
|
+
|
|
8
|
+
/** Whether a change to `path` (relative to the package's `src/`) makes the running view stale. */
|
|
9
|
+
export const isRestartTrigger = (path: string): boolean => {
|
|
10
|
+
const segments = path.split(/[\\/]/)
|
|
11
|
+
if (segments.includes('node_modules')) return false
|
|
12
|
+
return path.endsWith('.ts') && !path.endsWith('.test.ts') && !path.endsWith('.d.ts')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether this process is the supervised view rather than the supervisor. */
|
|
16
|
+
export const isSupervisedChild = (env: Record<string, string | undefined>): boolean => env[CHILD_MARKER] === '1'
|
|
17
|
+
|
|
18
|
+
export type ChildExit = {
|
|
19
|
+
restarting: boolean
|
|
20
|
+
stopping: boolean
|
|
21
|
+
code: number | null
|
|
22
|
+
signal: NodeJS.Signals | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type NextStep = 'respawn' | 'wait' | 'exit'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the supervisor does once the child has ended. Only a wanted end stops it: a forwarded
|
|
29
|
+
* signal (`stopping`) or a clean exit code 0 (`q`, Ctrl-C in raw mode). A restart we asked for
|
|
30
|
+
* respawns; any other end is a crash, and the supervisor waits for the next change to the source.
|
|
31
|
+
*/
|
|
32
|
+
export const nextStep = ({ restarting, stopping, code }: ChildExit): NextStep => {
|
|
33
|
+
if (stopping) return 'exit'
|
|
34
|
+
if (restarting) return 'respawn'
|
|
35
|
+
return code === 0 ? 'exit' : 'wait'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The process exit code for a child that ended with `code` or `signal` (shell convention: 128 + signal number). */
|
|
39
|
+
export const exitCodeOf = (code: number | null, signal: NodeJS.Signals | null): number => {
|
|
40
|
+
if (code !== null) return code
|
|
41
|
+
return signal ? 128 + (constants.signals[signal] ?? 0) : 0
|
|
42
|
+
}
|