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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { errorText } from '../plans/error-text'
|
|
4
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
5
|
+
import { closedMark, historyDate, withHistoryRow, withLaneClosed } from './close-board'
|
|
6
|
+
import { BOARD_LANE, type ClosableLane, type CloseFacts, closeVerdict } from './close-check'
|
|
7
|
+
import { LANE_CLOSE_COMMAND } from './close-command-line'
|
|
8
|
+
|
|
9
|
+
/** The facts the check reads, and the herdr workspace the worktree is open in, null when none is. */
|
|
10
|
+
export type CloseSurvey = { facts: CloseFacts; workspace: string | null }
|
|
11
|
+
|
|
12
|
+
/** What `jh lane close` was asked for; `worktree` comes from the settings and the lane's name, never from a reply. */
|
|
13
|
+
export type CloseRequest = {
|
|
14
|
+
lane: string
|
|
15
|
+
worktree: string
|
|
16
|
+
/** The board as the output names it, relative to the main checkout. */
|
|
17
|
+
boardPath: string
|
|
18
|
+
dryRun: boolean
|
|
19
|
+
now: Date
|
|
20
|
+
language: PlanLanguage
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The I/O `closeLane` drives; every port but `survey` and the printers throws when it fails. */
|
|
24
|
+
export type ClosePorts = {
|
|
25
|
+
survey: () => Promise<CloseSurvey>
|
|
26
|
+
/** Ends the agent in this pane and returns once the terminal no longer lists its session. */
|
|
27
|
+
exitAgent: (pane: string) => Promise<void>
|
|
28
|
+
/** Removes the worktree without force, then closes the terminal workspace `workspace` names, if any. */
|
|
29
|
+
removeWorktree: (worktree: string, workspace: string | null) => void
|
|
30
|
+
/** Deletes the branch only while it still points to `head`, the commit the check compared. */
|
|
31
|
+
deleteBranch: (branch: string, head: string) => void
|
|
32
|
+
readBoard: () => string
|
|
33
|
+
writeBoard: (text: string) => void
|
|
34
|
+
print: (line: string) => void
|
|
35
|
+
printError: (line: string) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ClosedLane = { lane: string } & ClosableLane
|
|
39
|
+
|
|
40
|
+
type Step = { label: string; run: () => Promise<void> | void }
|
|
41
|
+
|
|
42
|
+
/** A merge commit as the history row cites it. */
|
|
43
|
+
const SHORT_COMMIT_LENGTH = 7
|
|
44
|
+
|
|
45
|
+
const HISTORY_TEXT: Record<PlanLanguage, (closed: ClosedLane, mergeCommit: string) => string> = {
|
|
46
|
+
[PLAN_LANGUAGE.de]: ({ lane, branch, pullRequest }, mergeCommit) =>
|
|
47
|
+
`Spur \`${lane}\` abgeräumt mit \`${LANE_CLOSE_COMMAND}\`: PR #${pullRequest.number} gemergt (\`${mergeCommit}\`), Baum gleich Merge, Worktree entfernt, lokaler Branch \`${branch}\` gelöscht`,
|
|
48
|
+
[PLAN_LANGUAGE.en]: ({ lane, branch, pullRequest }, mergeCommit) =>
|
|
49
|
+
`Lane \`${lane}\` cleaned up with \`${LANE_CLOSE_COMMAND}\`: PR #${pullRequest.number} merged (\`${mergeCommit}\`), tree equal to the merge, worktree removed, local branch \`${branch}\` deleted`,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const clockOf = (time: Date): string => `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}`
|
|
53
|
+
|
|
54
|
+
/** The history row's text: the time, then what was closed and proven, in the board's language. */
|
|
55
|
+
export const historyText = (closed: ClosedLane, request: Pick<CloseRequest, 'now' | 'language'>): string =>
|
|
56
|
+
`${clockOf(request.now)}: ${HISTORY_TEXT[request.language](closed, closed.pullRequest.mergeCommit.slice(0, SHORT_COMMIT_LENGTH))}`
|
|
57
|
+
|
|
58
|
+
/** The board with the lane's rows marked and the history row on top. */
|
|
59
|
+
const closedBoard = (board: string, closed: ClosedLane, request: CloseRequest): string =>
|
|
60
|
+
withHistoryRow(
|
|
61
|
+
withLaneClosed(board, closed.lane, closedMark(request.now, request.language), request.language),
|
|
62
|
+
historyDate(request.now),
|
|
63
|
+
historyText(closed, request),
|
|
64
|
+
request.language,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const boardLabel = (facts: CloseFacts, request: CloseRequest): string =>
|
|
68
|
+
facts.boardLane === BOARD_LANE.closed
|
|
69
|
+
? `add a history row in ${request.boardPath}, whose rows mark ${request.lane} already`
|
|
70
|
+
: `mark the rows of ${request.lane} "${closedMark(request.now, request.language)}" and add a history row in ${request.boardPath}`
|
|
71
|
+
|
|
72
|
+
/** The removal steps after the agent is gone, in the order that never leaves the board claiming more than was done. */
|
|
73
|
+
const removalSteps = (closed: ClosedLane, survey: CloseSurvey, request: CloseRequest, ports: ClosePorts): Step[] => {
|
|
74
|
+
const { workspace, facts } = survey
|
|
75
|
+
const place = workspace === null ? '' : ` and close workspace ${workspace}`
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
label: `remove worktree ${request.worktree}${place}`,
|
|
79
|
+
run: () => ports.removeWorktree(request.worktree, workspace),
|
|
80
|
+
},
|
|
81
|
+
{ label: `delete branch ${closed.branch}`, run: () => ports.deleteBranch(closed.branch, closed.head) },
|
|
82
|
+
{ label: boardLabel(facts, request), run: () => ports.writeBoard(closedBoard(ports.readBoard(), closed, request)) },
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const exitSteps = ({ facts }: CloseSurvey, request: CloseRequest, ports: ClosePorts): Step[] => {
|
|
87
|
+
const { agent } = facts
|
|
88
|
+
if (agent === null) return []
|
|
89
|
+
return [
|
|
90
|
+
{ label: `exit the agent of ${request.lane} and wait until it is gone`, run: () => ports.exitAgent(agent.pane) },
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The lane closable from `survey`, or null after printing every reason against it under `heading`. */
|
|
95
|
+
const checked = (survey: CloseSurvey, heading: string, request: CloseRequest, ports: ClosePorts): ClosedLane | null => {
|
|
96
|
+
const verdict = closeVerdict(survey.facts)
|
|
97
|
+
if (verdict.kind === 'closable') return { lane: request.lane, ...verdict }
|
|
98
|
+
ports.printError(heading)
|
|
99
|
+
for (const refusal of verdict.refusals) ports.printError(` ${terminalSafe(refusal)}`)
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Runs the steps in order, one line each; at the first failure, names it and every step left undone. */
|
|
104
|
+
const runSteps = async (steps: readonly Step[], ports: ClosePorts): Promise<boolean> => {
|
|
105
|
+
for (const [index, step] of steps.entries()) {
|
|
106
|
+
try {
|
|
107
|
+
await step.run()
|
|
108
|
+
ports.print(`done: ${step.label}`)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
ports.printError(`failed: ${step.label}: ${terminalSafe(errorText(error))}`)
|
|
111
|
+
for (const left of steps.slice(index + 1)) ports.printError(`not done: ${left.label}`)
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Closes a lane in one run, or refuses with every reason and changes nothing. Checks first (`closeVerdict`); a dry run
|
|
120
|
+
* stops there and prints the steps. Otherwise it ends the lane's agent, checks again, since the agent may have written
|
|
121
|
+
* until it exited, then removes the worktree and its workspace, deletes the local branch, and marks the board last, so
|
|
122
|
+
* the board never records a removal that failed. Exit 0 closed or dry run, 1 refused or failed.
|
|
123
|
+
*/
|
|
124
|
+
export const closeLane = async (request: CloseRequest, ports: ClosePorts): Promise<number> => {
|
|
125
|
+
const first = await ports.survey()
|
|
126
|
+
const closed = checked(first, `not closing ${request.lane}:`, request, ports)
|
|
127
|
+
if (closed === null) return 1
|
|
128
|
+
const exit = exitSteps(first, request, ports)
|
|
129
|
+
if (request.dryRun) {
|
|
130
|
+
ports.print(`dry run, nothing changed; closing ${request.lane} would:`)
|
|
131
|
+
for (const step of [...exit, ...removalSteps(closed, first, request, ports)]) ports.print(` ${step.label}`)
|
|
132
|
+
return 0
|
|
133
|
+
}
|
|
134
|
+
if (!(await runSteps(exit, ports))) return 1
|
|
135
|
+
const settled = exit.length === 0 ? first : await ports.survey()
|
|
136
|
+
const late = checked(settled, `not closing ${request.lane} after its agent exited:`, request, ports)
|
|
137
|
+
if (late === null) return 1
|
|
138
|
+
if (!(await runSteps(removalSteps(late, settled, request, ports), ports))) return 1
|
|
139
|
+
ports.print(`closed ${request.lane}`)
|
|
140
|
+
return 0
|
|
141
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { run as runPrompt } from '../lane-prompt/command'
|
|
2
|
+
|
|
3
|
+
const CLOSE = 'close'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `jh lane close …` closes a lane, everything else is `jh lane prompt …`. `close` must come first, so the ask rule
|
|
7
|
+
* `jh lane close` always sees it; `jh lane --board b.md close x` reaches the prompt parser and is refused there.
|
|
8
|
+
*/
|
|
9
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
10
|
+
if (args[0] !== CLOSE) return runPrompt(args, cwd)
|
|
11
|
+
const { run: runClose } = await import('./close-command')
|
|
12
|
+
return runClose(args.slice(1), cwd)
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The line that ends a written lane prompt; what the coordinator adds below it survives every new writing. */
|
|
2
|
+
export const ADDITIONS_MARKER = '<!-- jackherd: additions below this line stay when the prompt is written again -->'
|
|
3
|
+
|
|
4
|
+
/** What follows the marker line of a written prompt; empty for no file, no marker line or nothing below it. */
|
|
5
|
+
export const additionsOf = (existing: string | null): string => {
|
|
6
|
+
if (existing === null) return ''
|
|
7
|
+
const lines = existing.split('\n')
|
|
8
|
+
const marker = lines.indexOf(ADDITIONS_MARKER)
|
|
9
|
+
return marker === -1 ? '' : lines.slice(marker + 1).join('\n')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `prompt` closed by the marker and by the additions of the file it replaces, so writing twice changes nothing. */
|
|
13
|
+
export const withAdditions = (prompt: string, existing: string | null): string =>
|
|
14
|
+
`${prompt}\n${ADDITIONS_MARKER}\n${additionsOf(existing)}`
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { phaseLabel } from '../plan-format/lanes'
|
|
2
|
+
import { LANE_PROMPT_OPENING, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { laneReportFile } from '../watch/lane-report-file'
|
|
4
|
+
import { LANE_PROMPT_TEXTS, type LanePromptTexts } from './texts'
|
|
5
|
+
|
|
6
|
+
/** A phase of the lane with the paths its row of the dependency table writes (`''` is the whole repository); null without a row. */
|
|
7
|
+
export type LanePhase = { id: string; writes: string[] | null }
|
|
8
|
+
|
|
9
|
+
/** A plan the lane works on, by its path from the repository root, with the lane's phases in it. */
|
|
10
|
+
export type LanePlan = { path: string; phases: LanePhase[] }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Everything a lane prompt is made of. `port` is null when the board gives the lane none; `handover` names the
|
|
14
|
+
* hand-over file a restarted lane reads first (`/orchestrate` § 8), null for a fresh start.
|
|
15
|
+
*/
|
|
16
|
+
export type LanePromptInput = {
|
|
17
|
+
language: PlanLanguage
|
|
18
|
+
lane: string
|
|
19
|
+
plans: LanePlan[]
|
|
20
|
+
port: number | null
|
|
21
|
+
handover: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const writesText = (language: PlanLanguage, texts: LanePromptTexts, writes: string[] | null): string => {
|
|
25
|
+
if (writes === null) return texts.noWrites
|
|
26
|
+
const paths = writes.map((path) => (path === '' ? texts.wholeRepository : `\`${path}\``)).join(', ')
|
|
27
|
+
return `${PLAN_VOCABULARY[language].columns.writes}: ${paths}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const phaseText = (language: PlanLanguage, texts: LanePromptTexts, phase: LanePhase): string =>
|
|
31
|
+
`\`${PLAN_VOCABULARY[language].phase} ${phaseLabel(phase.id)}\` (${writesText(language, texts, phase.writes)})`
|
|
32
|
+
|
|
33
|
+
const planLines = (language: PlanLanguage, texts: LanePromptTexts, plan: LanePlan): string[] => [
|
|
34
|
+
`- \`${plan.path}\``,
|
|
35
|
+
...plan.phases.map((phase) => ` - ${phaseText(language, texts, phase)}`),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const ruleLines = (input: LanePromptInput, texts: LanePromptTexts): string[] => [
|
|
39
|
+
input.port === null ? texts.noPort : texts.port(input.port),
|
|
40
|
+
texts.blocking,
|
|
41
|
+
texts.mustNot,
|
|
42
|
+
texts.commit,
|
|
43
|
+
texts.report(laneReportFile(input.lane)),
|
|
44
|
+
texts.textBlock,
|
|
45
|
+
texts.secret,
|
|
46
|
+
texts.askRule,
|
|
47
|
+
texts.approvalGate,
|
|
48
|
+
texts.helper(`${input.lane}-${texts.helperPurpose}`),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The prompt that starts a lane, in the plan language: a first line that names the lane (the words the ask hint hook
|
|
53
|
+
* and the project's lane exception recognise), the plans with their phases as `Phase P<n>` and each phase's write
|
|
54
|
+
* set, the rules of `/orchestrate` § 1 that `/implement` does not carry already, and the start: `/implement`, or the
|
|
55
|
+
* hand-over file after a restart.
|
|
56
|
+
*/
|
|
57
|
+
export const buildLanePrompt = (input: LanePromptInput): string => {
|
|
58
|
+
const texts = LANE_PROMPT_TEXTS[input.language]
|
|
59
|
+
return [
|
|
60
|
+
`${LANE_PROMPT_OPENING[input.language]} ${input.lane}. ${texts.answersTo}`,
|
|
61
|
+
'',
|
|
62
|
+
texts.phasesIntro,
|
|
63
|
+
'',
|
|
64
|
+
...input.plans.flatMap((plan) => planLines(input.language, texts, plan)),
|
|
65
|
+
'',
|
|
66
|
+
texts.editInside,
|
|
67
|
+
texts.writeTools,
|
|
68
|
+
'',
|
|
69
|
+
...ruleLines(input, texts).map((line) => `- ${line}`),
|
|
70
|
+
'',
|
|
71
|
+
input.handover === null ? texts.start : texts.restart(input.handover),
|
|
72
|
+
'',
|
|
73
|
+
].join('\n')
|
|
74
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import { boardFor } from '../plans/board-pick'
|
|
5
|
+
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
6
|
+
import { boardRepoPathOf, readSettings, worktreeOf } from '../plans/settings'
|
|
7
|
+
import { asksForHelp } from '../preview/options'
|
|
8
|
+
import { withAdditions } from './additions'
|
|
9
|
+
import { buildLanePrompt } from './build'
|
|
10
|
+
import { type BoardSource, type LanePromptRequest, lanePromptInputOf, type ReadPlan } from './input'
|
|
11
|
+
import { LANE_PROMPT_USAGE, type LanePromptOptions, parseLanePromptOptions } from './options'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What `lanePromptInputOf` reads, located from the command line, and the main checkout the default file goes to;
|
|
15
|
+
* `readLanePlan` reads from the lane's worktree, null for every plan while it has none.
|
|
16
|
+
*/
|
|
17
|
+
type PromptSources = {
|
|
18
|
+
request: LanePromptRequest
|
|
19
|
+
board: BoardSource
|
|
20
|
+
readPlan: ReadPlan
|
|
21
|
+
readLanePlan: ReadPlan
|
|
22
|
+
root: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Where the prompt goes without `--out`: the hand-over directory of the main checkout, which git ignores. */
|
|
26
|
+
export const defaultPromptFile = (root: string, lane: string): string =>
|
|
27
|
+
join(root, claudePath(HANDOVER_DIRECTORY), `${lane}-prompt.md`)
|
|
28
|
+
|
|
29
|
+
/** The first of `roots` that holds `path` as a regular file, read; null when none does. */
|
|
30
|
+
const readFirstIn =
|
|
31
|
+
(roots: readonly string[]) =>
|
|
32
|
+
(path: string): string | null => {
|
|
33
|
+
const file = roots
|
|
34
|
+
.map((root) => join(root, path))
|
|
35
|
+
.find((candidate) => existsSync(candidate) && statSync(candidate).isFile())
|
|
36
|
+
return file === undefined ? null : readFileSync(file, 'utf8')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The lane's request, its board and the plan reader, from the checkouts of `cwd`; prints the board pick's notice. */
|
|
40
|
+
const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources => {
|
|
41
|
+
const checkout = worktreeRoot(cwd)
|
|
42
|
+
const root = mainCheckout(cwd)
|
|
43
|
+
const settings = readSettings([checkout, root])
|
|
44
|
+
const boardPath = options.boardPath === null ? null : resolve(cwd, options.boardPath)
|
|
45
|
+
const board = boardFor(boardPath, [root, checkout], settings, () => [options.lane])
|
|
46
|
+
if (board.notice) console.error(board.notice)
|
|
47
|
+
const directory = dirname(boardRepoPathOf(root, settings, board.file))
|
|
48
|
+
return {
|
|
49
|
+
request: { lane: options.lane, handover: options.handover, language: settings.planLanguage },
|
|
50
|
+
board: { text: readFileSync(board.file, 'utf8'), directory, path: relative(cwd, board.file) },
|
|
51
|
+
readPlan: readFirstIn([root, checkout]),
|
|
52
|
+
readLanePlan: readFirstIn([worktreeOf(root, settings, options.lane)]),
|
|
53
|
+
root,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Writes `prompt` to `out`, closed by the marker and by the additions the file there already carries below it. */
|
|
58
|
+
const writePrompt = (out: string, prompt: string): void => {
|
|
59
|
+
const existing = existsSync(out) ? readFileSync(out, 'utf8') : null
|
|
60
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
61
|
+
writeFileSync(out, withAdditions(prompt, existing))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `jh lane prompt <lane> [--board <board.md>] [--out <file>] [--handover <file>]`: writes the lane's prompt, built
|
|
66
|
+
* from its board row and its plan in the plan language, and prints the file's path, so the coordinator sends it with
|
|
67
|
+
* `herdr agent prompt <lane> "$(cat <file>)"`. Below the marker line at the file's end the coordinator adds what the
|
|
68
|
+
* plan does not say; it stays through every new writing. A phase two open rows name is in no prompt and is reported on
|
|
69
|
+
* stderr. `--help` prints the usage. Exit 0 written, 1 refused or failed.
|
|
70
|
+
*/
|
|
71
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
72
|
+
if (asksForHelp(args)) {
|
|
73
|
+
console.log(LANE_PROMPT_USAGE)
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const options = parseLanePromptOptions(args)
|
|
78
|
+
const { request, board, readPlan, readLanePlan, root } = promptSourcesOf(options, cwd)
|
|
79
|
+
const result = lanePromptInputOf(request, board, readPlan, readLanePlan)
|
|
80
|
+
if (result.kind === 'refused') {
|
|
81
|
+
console.error(result.reason)
|
|
82
|
+
return 1
|
|
83
|
+
}
|
|
84
|
+
for (const notice of result.notices ?? []) console.error(notice)
|
|
85
|
+
const out = options.out === null ? defaultPromptFile(root, options.lane) : resolve(cwd, options.out)
|
|
86
|
+
writePrompt(out, buildLanePrompt(result.input))
|
|
87
|
+
console.log(out)
|
|
88
|
+
return 0
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
91
|
+
return 1
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type PhaseDependency, parseDependencyTable, writeSetOf } from '../plan-format/dependencies'
|
|
2
|
+
import { parseSprintLanes, phaseLabel, resolvePlanPath, type SprintLane } from '../plan-format/lanes'
|
|
3
|
+
import { parsePlanPhases } from '../plan-format/phases'
|
|
4
|
+
import { isPhaseFinished } from '../plan-format/progress'
|
|
5
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
6
|
+
import type { LanePhase, LanePlan, LanePromptInput } from './build'
|
|
7
|
+
|
|
8
|
+
/** The lane to prompt, the hand-over file of a restart (null for a fresh start) and the plan language. */
|
|
9
|
+
export type LanePromptRequest = { lane: string; handover: string | null; language: PlanLanguage }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The board's text, its directory from the repository root, which its plan links are relative to, and the path a
|
|
13
|
+
* refusal names it by.
|
|
14
|
+
*/
|
|
15
|
+
export type BoardSource = { text: string; directory: string; path: string }
|
|
16
|
+
|
|
17
|
+
/** A plan's text by its path from the repository root; null when it cannot be read. */
|
|
18
|
+
export type ReadPlan = (path: string) => string | null
|
|
19
|
+
|
|
20
|
+
/** `notices` is absent unless the board needs fixing: a phase two open rows name is in neither prompt. */
|
|
21
|
+
export type LanePromptResult =
|
|
22
|
+
| { kind: 'prompt'; input: LanePromptInput; notices?: string[] }
|
|
23
|
+
| { kind: 'refused'; reason: string }
|
|
24
|
+
|
|
25
|
+
type PlanSteps = { path: string; ids: string[] }
|
|
26
|
+
|
|
27
|
+
type Refusal = { reason: string }
|
|
28
|
+
|
|
29
|
+
type PlanOutcome = { plan: LanePlan } | Refusal
|
|
30
|
+
|
|
31
|
+
type PlansOutcome = { plans: LanePlan[] } | Refusal
|
|
32
|
+
|
|
33
|
+
const refused = (reason: string): LanePromptResult => ({ kind: 'refused', reason })
|
|
34
|
+
|
|
35
|
+
/** The lane's plans by path in board order, each with the phase ids its rows name, rows of one plan merged. */
|
|
36
|
+
const planStepsOf = (lane: SprintLane, directory: string): PlanSteps[] => {
|
|
37
|
+
const byPath = new Map<string, string[]>()
|
|
38
|
+
for (const step of lane.steps) {
|
|
39
|
+
if (step.planHref === null || step.phaseIds.length === 0) continue
|
|
40
|
+
const path = resolvePlanPath(directory, step.planHref)
|
|
41
|
+
byPath.set(path, [...new Set([...(byPath.get(path) ?? []), ...step.phaseIds])])
|
|
42
|
+
}
|
|
43
|
+
return [...byPath].map(([path, ids]) => ({ path, ids }))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The write set of the phase `id`; null without a row, for an unknown target or a cycle. */
|
|
47
|
+
const phaseWriteSet = (rows: readonly PhaseDependency[], id: string): string[] | null => {
|
|
48
|
+
const row = rows.find((candidate) => candidate.id === id)
|
|
49
|
+
return row ? writeSetOf(row, new Map(rows.map((candidate) => [candidate.id, candidate]))) : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A plan's text on the lane branch (null without a worktree or a copy there) and in the main checkout. */
|
|
53
|
+
type PlanTexts = { lane: string | null; main: string | null }
|
|
54
|
+
|
|
55
|
+
const finishedIdsOf = (text: string | null, language: PlanLanguage): Set<string> =>
|
|
56
|
+
new Set(
|
|
57
|
+
text === null ? [] : parsePlanPhases(text, language).flatMap((phase) => (isPhaseFinished(phase) ? [phase.id] : [])),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The plan's open named phases, read from the lane branch when it has a copy, which holds the phases the lane wrote
|
|
62
|
+
* and the ticks it set; a phase finished in either copy is left out, so a lane branch behind the main checkout does not
|
|
63
|
+
* bring back what another lane finished.
|
|
64
|
+
*/
|
|
65
|
+
const lanePlanOf = (steps: PlanSteps, texts: PlanTexts, text: string, language: PlanLanguage): PlanOutcome => {
|
|
66
|
+
const phases = parsePlanPhases(text, language)
|
|
67
|
+
const named = steps.ids.map((id) => ({ id, phase: phases.find((phase) => phase.id === id) }))
|
|
68
|
+
const missing = named.find(({ phase }) => phase === undefined)
|
|
69
|
+
if (missing) return { reason: `${steps.path} has no phase ${phaseLabel(missing.id)}` }
|
|
70
|
+
const rows = parseDependencyTable(text, language) ?? []
|
|
71
|
+
const finishedInMain = finishedIdsOf(texts.main, language)
|
|
72
|
+
const open = named.filter(
|
|
73
|
+
({ id, phase }) => phase !== undefined && !isPhaseFinished(phase) && !finishedInMain.has(id),
|
|
74
|
+
)
|
|
75
|
+
const lanePhases: LanePhase[] = open.map(({ id }) => ({ id, writes: phaseWriteSet(rows, id) }))
|
|
76
|
+
return { plan: { path: steps.path, phases: lanePhases } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const plansOf = (
|
|
80
|
+
steps: readonly PlanSteps[],
|
|
81
|
+
readers: { main: ReadPlan; lane: ReadPlan },
|
|
82
|
+
language: PlanLanguage,
|
|
83
|
+
): PlansOutcome => {
|
|
84
|
+
const plans: LanePlan[] = []
|
|
85
|
+
for (const step of steps) {
|
|
86
|
+
const texts = { lane: readers.lane(step.path), main: readers.main(step.path) }
|
|
87
|
+
const text = texts.lane ?? texts.main
|
|
88
|
+
if (text === null) return { reason: `cannot read the plan ${step.path}` }
|
|
89
|
+
const outcome = lanePlanOf(step, texts, text, language)
|
|
90
|
+
if ('reason' in outcome) return outcome
|
|
91
|
+
if (outcome.plan.phases.length > 0) plans.push(outcome.plan)
|
|
92
|
+
}
|
|
93
|
+
return { plans }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The other open lanes whose rows name the phase `id` of the plan at `path`, by name. */
|
|
97
|
+
const otherNamersOf = (others: readonly SprintLane[], directory: string, path: string, id: string): string[] =>
|
|
98
|
+
others
|
|
99
|
+
.filter((lane) => planStepsOf(lane, directory).some((steps) => steps.path === path && steps.ids.includes(id)))
|
|
100
|
+
.map((lane) => lane.name)
|
|
101
|
+
|
|
102
|
+
const withoutContested = (
|
|
103
|
+
plans: readonly LanePlan[],
|
|
104
|
+
lane: string,
|
|
105
|
+
others: readonly SprintLane[],
|
|
106
|
+
directory: string,
|
|
107
|
+
): { plans: LanePlan[]; notices: string[] } => {
|
|
108
|
+
const judged = plans.map((plan) => ({
|
|
109
|
+
plan,
|
|
110
|
+
phases: plan.phases.map((phase) => ({ phase, namers: otherNamersOf(others, directory, plan.path, phase.id) })),
|
|
111
|
+
}))
|
|
112
|
+
const kept = judged.map(({ plan, phases }) => ({
|
|
113
|
+
...plan,
|
|
114
|
+
phases: phases.filter(({ namers }) => namers.length === 0).map(({ phase }) => phase),
|
|
115
|
+
}))
|
|
116
|
+
const notices = judged.flatMap(({ plan, phases }) =>
|
|
117
|
+
phases
|
|
118
|
+
.filter(({ namers }) => namers.length > 0)
|
|
119
|
+
.map(
|
|
120
|
+
({ phase, namers }) =>
|
|
121
|
+
`${plan.path} ${phaseLabel(phase.id)} is named by ${[lane, ...namers].sort().join(' and ')}, so it is in neither prompt; fix the board`,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
return { plans: kept.filter((plan) => plan.phases.length > 0), notices }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What the lane's prompt is built from: its open row of the board's lanes table (port, plans, phases), each named
|
|
129
|
+
* phase that is not finished, and the write set of each from its plan's dependency table. A plan is read from the lane
|
|
130
|
+
* branch (`readLanePlan`) when it has a copy, else from the main checkout (`readPlan`). Refused, with the reason,
|
|
131
|
+
* for a lane without a row, a closed lane, rows that name no phase, a plan that cannot be read or lacks a phase the
|
|
132
|
+
* row names, and a lane whose phases are all finished.
|
|
133
|
+
*/
|
|
134
|
+
export const lanePromptInputOf = (
|
|
135
|
+
request: LanePromptRequest,
|
|
136
|
+
board: BoardSource,
|
|
137
|
+
readPlan: ReadPlan,
|
|
138
|
+
readLanePlan: ReadPlan = () => null,
|
|
139
|
+
): LanePromptResult => {
|
|
140
|
+
const lanes = parseSprintLanes(board.text, request.language)
|
|
141
|
+
const sprintLane = lanes.find((candidate) => candidate.name === request.lane)
|
|
142
|
+
if (!sprintLane) return refused(`no row for lane ${request.lane} in ${board.path}`)
|
|
143
|
+
if (sprintLane.closed) return refused(`lane ${request.lane} is closed in ${board.path}`)
|
|
144
|
+
const steps = planStepsOf(sprintLane, board.directory)
|
|
145
|
+
if (steps.length === 0) return refused(`the row of lane ${request.lane} in ${board.path} links no plan with a phase`)
|
|
146
|
+
const found = plansOf(steps, { main: readPlan, lane: readLanePlan }, request.language)
|
|
147
|
+
if ('reason' in found) return refused(found.reason)
|
|
148
|
+
if (found.plans.length === 0) return refused(`every phase of lane ${request.lane} is finished`)
|
|
149
|
+
const { lane, handover, language } = request
|
|
150
|
+
const others = lanes.filter((candidate) => candidate.name !== lane && !candidate.closed)
|
|
151
|
+
const { plans, notices } = withoutContested(found.plans, lane, others, board.directory)
|
|
152
|
+
if (plans.length === 0) {
|
|
153
|
+
return refused(`every open phase of lane ${lane} is also named by another lane: ${notices.join('; ')}`)
|
|
154
|
+
}
|
|
155
|
+
const input = { language, lane, plans, port: sprintLane.port, handover }
|
|
156
|
+
return notices.length === 0 ? { kind: 'prompt', input } : { kind: 'prompt', input, notices }
|
|
157
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
3
|
+
|
|
4
|
+
/** What `jh lane prompt` was asked for; null where an option was left out. */
|
|
5
|
+
export type LanePromptOptions = { lane: string; boardPath: string | null; out: string | null; handover: string | null }
|
|
6
|
+
|
|
7
|
+
const VALUE_OPTIONS = ['--board', '--out', '--handover'] as const
|
|
8
|
+
|
|
9
|
+
type ValueOption = (typeof VALUE_OPTIONS)[number]
|
|
10
|
+
|
|
11
|
+
const SUBCOMMAND = 'prompt'
|
|
12
|
+
|
|
13
|
+
export const LANE_PROMPT_USAGE = `usage: ${CLI_NAME} lane prompt <lane> [--board <board.md>] [--out <file>] [--handover <file>]
|
|
14
|
+
|
|
15
|
+
Writes the prompt that starts the lane, from its row in the board and its plan, and prints the file's path.
|
|
16
|
+
What you add below the marker line at the end of the file stays when the prompt is written again.
|
|
17
|
+
A phase that two open rows of the board name is in neither prompt; the command reports it.
|
|
18
|
+
--board <board.md> the board to read; without it the board is picked as for ${CLI_NAME} herd
|
|
19
|
+
--out <file> where to write the prompt; default .claude/handover/<lane>-prompt.md in the main checkout
|
|
20
|
+
--handover <file> a restart after a hand-over: the lane reads this file first and continues with its next step`
|
|
21
|
+
|
|
22
|
+
const usageError = (message: string): Error => new Error(`${message}\n${LANE_PROMPT_USAGE}`)
|
|
23
|
+
|
|
24
|
+
const isValueOption = (arg: string): arg is ValueOption => (VALUE_OPTIONS as readonly string[]).includes(arg)
|
|
25
|
+
|
|
26
|
+
const splitArguments = (args: readonly string[]): { values: Map<ValueOption, string>; words: string[] } => {
|
|
27
|
+
const values = new Map<ValueOption, string>()
|
|
28
|
+
const words: string[] = []
|
|
29
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
30
|
+
const arg = args[i]
|
|
31
|
+
if (!arg.startsWith('--')) {
|
|
32
|
+
words.push(arg)
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (!isValueOption(arg)) throw usageError(`unknown option ${arg}`)
|
|
36
|
+
const value = args[i + 1]
|
|
37
|
+
if (value === undefined || value.startsWith('--')) throw usageError(`${arg} needs a value`)
|
|
38
|
+
values.set(arg, value)
|
|
39
|
+
i += 1
|
|
40
|
+
}
|
|
41
|
+
return { values, words }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const laneOf = (words: readonly string[]): string => {
|
|
45
|
+
const [subcommand, ...lanes] = words
|
|
46
|
+
if (subcommand !== SUBCOMMAND)
|
|
47
|
+
throw usageError(subcommand ? `unknown subcommand ${subcommand}` : 'the subcommand is required')
|
|
48
|
+
if (lanes.length === 0) throw usageError('the lane is required')
|
|
49
|
+
if (lanes.length > 1) throw usageError(`one lane at most, got ${lanes.join(' ')}`)
|
|
50
|
+
if (!SAFE_LANE_NAME.test(lanes[0])) throw usageError(`${lanes[0]} is no lane name (letters, digits, . _ -)`)
|
|
51
|
+
return lanes[0]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The hand-over path as the prompt quotes it in backticks: one line without a backtick. */
|
|
55
|
+
const handoverOf = (value: string | undefined): string | null => {
|
|
56
|
+
if (value === undefined) return null
|
|
57
|
+
if (/[`\r\n]/.test(value)) throw usageError('--handover needs a path without backticks or line breaks')
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reads `prompt <lane> [--board …] [--out …] [--handover …]` in any order; throws on a wrong command line. */
|
|
62
|
+
export const parseLanePromptOptions = (args: readonly string[]): LanePromptOptions => {
|
|
63
|
+
const { values, words } = splitArguments(args)
|
|
64
|
+
return {
|
|
65
|
+
lane: laneOf(words),
|
|
66
|
+
boardPath: values.get('--board') ?? null,
|
|
67
|
+
out: values.get('--out') ?? null,
|
|
68
|
+
handover: handoverOf(values.get('--handover')),
|
|
69
|
+
}
|
|
70
|
+
}
|