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
|
@@ -2,15 +2,21 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import {
|
|
4
4
|
boardRowsWithoutPhase,
|
|
5
|
+
boardRowsWithProse,
|
|
5
6
|
hasLanesTable,
|
|
6
7
|
type MergedPhaseWithOpenBoxes,
|
|
7
8
|
mergedPhasesWithOpenBoxes,
|
|
8
9
|
newPhasesWithoutReviewGate,
|
|
10
|
+
PHASE_CELL_LIMIT,
|
|
9
11
|
type PhaseWithoutGate,
|
|
12
|
+
type PhaseWithoutRow,
|
|
10
13
|
PLAN_VOCABULARY,
|
|
11
14
|
type PlanLanguage,
|
|
15
|
+
PROSE_REASON,
|
|
12
16
|
phaseIdsOfPlan,
|
|
17
|
+
phasesWithoutDependencyRow,
|
|
13
18
|
type RowWithoutPhase,
|
|
19
|
+
type RowWithProse,
|
|
14
20
|
} from '../plan-format'
|
|
15
21
|
import { changedPlansOf } from './deferral-arrows'
|
|
16
22
|
import { CHECK_STATUS, type CheckResult } from './result'
|
|
@@ -31,60 +37,97 @@ export type PlanCheckInput = {
|
|
|
31
37
|
baseOf: ContentAt
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
type Changed = {
|
|
40
|
+
type Changed = {
|
|
41
|
+
path: string
|
|
42
|
+
current: string
|
|
43
|
+
previous: string | null
|
|
44
|
+
previousLines: ReadonlySet<string>
|
|
45
|
+
base: string | null
|
|
46
|
+
board: boolean
|
|
47
|
+
}
|
|
35
48
|
|
|
36
49
|
type Found<T> = T & { path: string }
|
|
37
50
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
type Report<T> = {
|
|
52
|
+
name: string
|
|
53
|
+
noun: 'plan' | 'board'
|
|
54
|
+
/** What the check asks of each changed file, as the command column reads after the count. */
|
|
55
|
+
rule: string
|
|
56
|
+
/** The status of a check with findings. */
|
|
57
|
+
unmet: typeof CHECK_STATUS.fail | typeof CHECK_STATUS.warn
|
|
58
|
+
text: (found: Found<T>) => string
|
|
59
|
+
clean: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const changedCount = (count: number, noun: string): string => `${count} changed ${count === 1 ? noun : `${noun}s`}`
|
|
63
|
+
|
|
64
|
+
const findingsResult = <T>(
|
|
65
|
+
{ name, noun, rule, unmet, text, clean }: Report<T>,
|
|
66
|
+
findings: readonly Found<T>[],
|
|
67
|
+
files: number,
|
|
42
68
|
durationMs: number,
|
|
43
69
|
): CheckResult => ({
|
|
44
|
-
name
|
|
45
|
-
command: `${
|
|
46
|
-
status: findings.length === 0 ? CHECK_STATUS.pass :
|
|
47
|
-
detail:
|
|
48
|
-
findings
|
|
49
|
-
.map(
|
|
50
|
-
({ path, line, id, title }) =>
|
|
51
|
-
`${path}:${line} Phase ${id} ${title} has tasks but no review gate; add one, or a **${PLAN_VOCABULARY[language].noReviewGate}:** line with the reason`,
|
|
52
|
-
)
|
|
53
|
-
.join('\n') || 'every new phase has a review gate',
|
|
70
|
+
name,
|
|
71
|
+
command: `${changedCount(files, noun)}, ${rule}`,
|
|
72
|
+
status: findings.length === 0 ? CHECK_STATUS.pass : unmet,
|
|
73
|
+
detail: findings.map(text).join('\n') || clean,
|
|
54
74
|
durationMs,
|
|
55
75
|
})
|
|
56
76
|
|
|
57
|
-
const
|
|
58
|
-
name: '
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`${path}:${line} lane ${lane}: "${phases}" names no phase (P<n>), so the view cannot tie the step to one`,
|
|
66
|
-
)
|
|
67
|
-
.join('\n') || 'every changed lane row names a phase',
|
|
68
|
-
durationMs,
|
|
77
|
+
const gateReport = (language: PlanLanguage): Report<PhaseWithoutGate> => ({
|
|
78
|
+
name: 'Plan review gates',
|
|
79
|
+
noun: 'plan',
|
|
80
|
+
rule: 'new phases need a review gate',
|
|
81
|
+
unmet: CHECK_STATUS.fail,
|
|
82
|
+
text: ({ path, line, id, title }) =>
|
|
83
|
+
`${path}:${line} Phase ${id} ${title} has tasks but no review gate; add one, or a **${PLAN_VOCABULARY[language].noReviewGate}:** line with the reason`,
|
|
84
|
+
clean: 'every new phase has a review gate',
|
|
69
85
|
})
|
|
70
86
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
const rowReport: Report<RowWithoutPhase> = {
|
|
88
|
+
name: 'Board rows without a phase',
|
|
89
|
+
noun: 'board',
|
|
90
|
+
rule: 'changed lane rows name a phase',
|
|
91
|
+
unmet: CHECK_STATUS.warn,
|
|
92
|
+
text: ({ path, line, lane, phases }) =>
|
|
93
|
+
`${path}:${line} lane ${lane}: "${phases}" names no phase (P<n>), so the herd cannot tie the step to one`,
|
|
94
|
+
clean: 'every changed lane row names a phase',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const proseText = ({ reason, length }: RowWithProse): string =>
|
|
98
|
+
reason === PROSE_REASON.sideOrder
|
|
99
|
+
? 'the phase cell records a side order of the user'
|
|
100
|
+
: `the phase cell has ${length} characters (limit ${PHASE_CELL_LIMIT})`
|
|
101
|
+
|
|
102
|
+
const proseReport: Report<RowWithProse> = {
|
|
103
|
+
name: 'Board rows with prose',
|
|
104
|
+
noun: 'board',
|
|
105
|
+
rule: 'changed lane rows name the phase only',
|
|
106
|
+
unmet: CHECK_STATUS.warn,
|
|
107
|
+
text: (found) =>
|
|
108
|
+
`${found.path}:${found.line} lane ${found.lane}: ${proseText(found)}; it belongs as a task in the plan`,
|
|
109
|
+
clean: 'every changed lane row names the phase only',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const mergedReport: Report<MergedPhaseWithOpenBoxes> = {
|
|
76
113
|
name: 'Merged phases with open boxes',
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
noun: 'plan',
|
|
115
|
+
rule: 'a merged phase ticks or moves on its open boxes',
|
|
116
|
+
unmet: CHECK_STATUS.warn,
|
|
117
|
+
text: ({ path, line, id, title, openLines }) =>
|
|
118
|
+
`${path}:${line} Phase ${id} ${title} is merged but has open boxes at ${openLines.join(', ')}; tick them or end them with → P<n>`,
|
|
119
|
+
clean: 'no merged phase has an open box left',
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const unlistedReport: Report<PhaseWithoutRow> = {
|
|
123
|
+
name: 'Phases without a dependency row',
|
|
124
|
+
noun: 'plan',
|
|
125
|
+
rule: 'every phase has a row in the dependency table',
|
|
126
|
+
unmet: CHECK_STATUS.warn,
|
|
127
|
+
text: ({ path, line, id, title }) =>
|
|
128
|
+
`${path}:${line} Phase ${id} ${title} has no row in the dependency table; add one with its dependencies, writes and serial marker`,
|
|
129
|
+
clean: 'every phase has a row in the dependency table',
|
|
130
|
+
}
|
|
88
131
|
|
|
89
132
|
const withPath =
|
|
90
133
|
<T>(path: string) =>
|
|
@@ -95,25 +138,38 @@ const gatesOf = ({ path, current, previous }: Changed, language: PlanLanguage):
|
|
|
95
138
|
withPath(path),
|
|
96
139
|
)
|
|
97
140
|
|
|
98
|
-
const rowsOf = ({ path, current,
|
|
99
|
-
boardRowsWithoutPhase(current, language,
|
|
141
|
+
const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
|
|
142
|
+
boardRowsWithoutPhase(current, language, previousLines).map(withPath(path))
|
|
143
|
+
|
|
144
|
+
const proseOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithProse>[] =>
|
|
145
|
+
boardRowsWithProse(current, language, previousLines).map(withPath(path))
|
|
100
146
|
|
|
101
147
|
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<MergedPhaseWithOpenBoxes>[] =>
|
|
102
148
|
base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
|
|
103
149
|
|
|
150
|
+
const unlistedOf = ({ path, current }: Changed, language: PlanLanguage): Found<PhaseWithoutRow>[] | null =>
|
|
151
|
+
phasesWithoutDependencyRow(current, language)?.map(withPath(path)) ?? null
|
|
152
|
+
|
|
104
153
|
const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput, plans: readonly string[]): Changed[] =>
|
|
105
154
|
plans
|
|
106
155
|
.filter((path) => existsSync(join(root, path)))
|
|
107
156
|
.map((path) => {
|
|
108
157
|
const current = readFileSync(join(root, path), 'utf8')
|
|
109
|
-
|
|
158
|
+
const previous = previousOf(path)
|
|
159
|
+
const previousLines = new Set(previous?.split('\n') ?? [])
|
|
160
|
+
return { path, current, previous, previousLines, base: baseOf(path), board: hasLanesTable(current, language) }
|
|
110
161
|
})
|
|
111
162
|
|
|
112
163
|
/**
|
|
113
|
-
* The structure of the changed plans and boards:
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
164
|
+
* The structure of the changed plans and boards:
|
|
165
|
+
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
|
|
166
|
+
* - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
|
|
167
|
+
* than `PHASE_CELL_LIMIT` or records a side order of the user;
|
|
168
|
+
* - a phase whose tasks are all ticked on the base branch and that still has open boxes without `→ P<n>` warns;
|
|
169
|
+
* - a phase of a plan with a dependency table that has no row in it warns.
|
|
170
|
+
*
|
|
171
|
+
* Empty when no plan changed. The row and prose results are left out when no changed file has a lanes table, the
|
|
172
|
+
* merged result when every one has, the dependency result when no changed plan has the table.
|
|
117
173
|
*/
|
|
118
174
|
export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
119
175
|
const files = changedPlansOf(input.paths, input.planDirectory)
|
|
@@ -123,13 +179,24 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
123
179
|
const changed = changedContents(input, files)
|
|
124
180
|
const boards = changed.filter(({ board }) => board)
|
|
125
181
|
const plans = changed.filter(({ board }) => !board)
|
|
182
|
+
const unlistedPerPlan = plans.map((file) => unlistedOf(file, language)).filter((found) => found !== null)
|
|
126
183
|
const gates = changed.flatMap((file) => gatesOf(file, language))
|
|
127
184
|
const rows = boards.flatMap((file) => rowsOf(file, language))
|
|
185
|
+
const prose = boards.flatMap((file) => proseOf(file, language))
|
|
128
186
|
const merged = plans.flatMap((file) => mergedOf(file, language))
|
|
187
|
+
const unlisted = unlistedPerPlan.flat()
|
|
129
188
|
const durationMs = Date.now() - started
|
|
130
189
|
return [
|
|
131
|
-
|
|
132
|
-
...(boards.length > 0
|
|
133
|
-
|
|
190
|
+
findingsResult(gateReport(language), gates, files.length, durationMs),
|
|
191
|
+
...(boards.length > 0
|
|
192
|
+
? [
|
|
193
|
+
findingsResult(rowReport, rows, boards.length, durationMs),
|
|
194
|
+
findingsResult(proseReport, prose, boards.length, durationMs),
|
|
195
|
+
]
|
|
196
|
+
: []),
|
|
197
|
+
...(plans.length > 0 ? [findingsResult(mergedReport, merged, plans.length, durationMs)] : []),
|
|
198
|
+
...(unlistedPerPlan.length > 0
|
|
199
|
+
? [findingsResult(unlistedReport, unlisted, unlistedPerPlan.length, durationMs)]
|
|
200
|
+
: []),
|
|
134
201
|
]
|
|
135
202
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { HERD_COMMAND } from './plans/herd-command'
|
|
3
4
|
import { locateKit } from './sync/kit-source'
|
|
4
5
|
import { CLI_NAME } from './version/release'
|
|
5
6
|
|
|
@@ -12,13 +13,17 @@ export type Resolution =
|
|
|
12
13
|
| { kind: 'version' }
|
|
13
14
|
| { kind: 'usage'; exitCode: number; message: string }
|
|
14
15
|
|
|
16
|
+
/** Commands that stay callable but are not advertised in the usage line. */
|
|
17
|
+
const ALIASES = new Set<string>([HERD_COMMAND.alias])
|
|
18
|
+
|
|
15
19
|
/** One line per command; each entry lazily imports the module that exports its `run`. */
|
|
16
20
|
export const COMMANDS: CommandTable = {
|
|
17
21
|
check: () => import('./check/command'),
|
|
18
22
|
config: () => import('./config/command'),
|
|
19
23
|
context: () => import('./context/command'),
|
|
24
|
+
[HERD_COMMAND.name]: () => import('./plans/command'),
|
|
20
25
|
init: () => import('./sync/init-command'),
|
|
21
|
-
|
|
26
|
+
[HERD_COMMAND.alias]: () => import('./plans/command'),
|
|
22
27
|
sync: () => import('./sync/command'),
|
|
23
28
|
update: () => import('./update/command'),
|
|
24
29
|
watch: () => import('./watch/command'),
|
|
@@ -27,7 +32,8 @@ export const COMMANDS: CommandTable = {
|
|
|
27
32
|
export const resolveCommand = (argv: string[], table: CommandTable): Resolution => {
|
|
28
33
|
const [name, ...args] = argv
|
|
29
34
|
const names = Object.keys(table)
|
|
30
|
-
const
|
|
35
|
+
const advertised = names.filter((entry) => !ALIASES.has(entry))
|
|
36
|
+
const usage = `Usage: ${CLI_NAME} <command> [args]\n ${CLI_NAME} --version\nCommands: ${advertised.length > 0 ? advertised.join(', ') : '(none yet)'}`
|
|
31
37
|
if (name === '--version' || name === '-v') return { kind: 'version' }
|
|
32
38
|
if (!name || name === '--help' || name === '-h') return { kind: 'usage', exitCode: name ? 0 : 1, message: usage }
|
|
33
39
|
if (!names.includes(name)) return { kind: 'usage', exitCode: 1, message: `Unknown command: ${name}\n${usage}` }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
type Group = { repeats: boolean; branches: string[] }
|
|
2
|
+
|
|
3
|
+
const GROUP_PREFIX = /^\?(?::|=|!|<=|<!|<[^>]+>)/
|
|
4
|
+
const BRACES = /^\{(\d+)(?:(,)(\d*))?\}/
|
|
5
|
+
|
|
6
|
+
/** Length of an unbounded or repeating quantifier (`*`, `+`, `{n,}`, `{n,m}` with m above 1) at `index`, else 0. */
|
|
7
|
+
const repeatingLengthAt = (pattern: string, index: number): number => {
|
|
8
|
+
if (pattern[index] === '*' || pattern[index] === '+') return 1
|
|
9
|
+
const braces = pattern.slice(index).match(BRACES)
|
|
10
|
+
const [text, , comma, max] = braces ?? []
|
|
11
|
+
return text !== undefined && comma && (max === '' || Number(max) > 1) ? text.length : 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const classEnd = (pattern: string, start: number): number => {
|
|
15
|
+
let index = start + 1
|
|
16
|
+
while (index < pattern.length && pattern[index] !== ']') index += pattern[index] === '\\' ? 2 : 1
|
|
17
|
+
return index
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const overlapsAnother = (branches: readonly string[]): boolean =>
|
|
21
|
+
branches.some((branch, index) =>
|
|
22
|
+
branches.some((other, otherIndex) => index !== otherIndex && other.startsWith(branch)),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const NESTED = -1
|
|
26
|
+
|
|
27
|
+
const appendTo = (group: Group, text: string): void => {
|
|
28
|
+
group.branches[group.branches.length - 1] += text
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const prefixLengthAt = (pattern: string, index: number): number =>
|
|
32
|
+
pattern.slice(index + 1).match(GROUP_PREFIX)?.[0].length ?? 0
|
|
33
|
+
|
|
34
|
+
/** Closes the group at `index`; NESTED when it repeats and holds a repeat or branches that begin alike. */
|
|
35
|
+
const closeGroup = (pattern: string, index: number, groups: Group[]): number => {
|
|
36
|
+
const closed = groups.pop() as Group
|
|
37
|
+
const repeated = repeatingLengthAt(pattern, index + 1) > 0
|
|
38
|
+
if (repeated && (closed.repeats || overlapsAnother(closed.branches))) return NESTED
|
|
39
|
+
groups[groups.length - 1].repeats ||= closed.repeats
|
|
40
|
+
return index + 1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reads the token at `index` into the open groups and returns the index after it, or NESTED. */
|
|
44
|
+
const stepAt = (pattern: string, index: number, groups: Group[]): number => {
|
|
45
|
+
const top = groups[groups.length - 1]
|
|
46
|
+
const char = pattern[index]
|
|
47
|
+
const literalEnd = char === '\\' ? index + 2 : classEnd(pattern, index) + 1
|
|
48
|
+
const quantifierLength = repeatingLengthAt(pattern, index)
|
|
49
|
+
if (char === '\\' || char === '[') appendTo(top, pattern.slice(index, literalEnd))
|
|
50
|
+
else if (char === '(') groups.push({ repeats: false, branches: [''] })
|
|
51
|
+
else if (char === ')' && groups.length > 1) return closeGroup(pattern, index, groups)
|
|
52
|
+
else if (char === '|') top.branches.push('')
|
|
53
|
+
else if (quantifierLength > 0) top.repeats = true
|
|
54
|
+
else appendTo(top, char)
|
|
55
|
+
if (char === '\\' || char === '[') return literalEnd
|
|
56
|
+
if (char === '(') return index + 1 + prefixLengthAt(pattern, index)
|
|
57
|
+
return index + Math.max(quantifierLength, 1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a regular expression repeats a group that repeats inside itself, the shape of catastrophic backtracking:
|
|
62
|
+
* `(a+)+`, `(\w*)*`, `(x+y)+z`, and with alternation whose branches begin alike, `(a|aa)+`. A best-effort scan of the
|
|
63
|
+
* source, not a proof: it misses overlaps it cannot see in the text (`(a|a?)+`), and it flags a few harmless ones.
|
|
64
|
+
*/
|
|
65
|
+
export const hasNestedQuantifier = (pattern: string): boolean => {
|
|
66
|
+
const groups: Group[] = [{ repeats: false, branches: [''] }]
|
|
67
|
+
let index = 0
|
|
68
|
+
while (index < pattern.length) {
|
|
69
|
+
index = stepAt(pattern, index, groups)
|
|
70
|
+
if (index === NESTED) return true
|
|
71
|
+
}
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path'
|
|
2
|
+
|
|
3
|
+
const segmentsOf = (path: string): string[] =>
|
|
4
|
+
path.split(/[\\/]/).filter((segment) => segment !== '' && segment !== '.')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A directory below the repository root: relative, not the root itself and without a `..` segment. The approval gate
|
|
8
|
+
* hook applies the same rule (`kit/hooks/hooks/approval-gate/settings.ts`), which this package does not import.
|
|
9
|
+
*/
|
|
10
|
+
export const isRepositoryDirectory = (value: string): boolean =>
|
|
11
|
+
!isAbsolute(value) && segmentsOf(value).length > 0 && !segmentsOf(value).includes('..')
|
package/src/config/schema.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { z } from 'zod'
|
|
|
2
2
|
import { checkConfigSchema } from '../check/schema'
|
|
3
3
|
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
|
|
5
|
-
import { SOURCE_FIELD } from '../watch/source-fields'
|
|
5
|
+
import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
|
|
6
6
|
import { isSourcePath } from '../watch/source-path'
|
|
7
|
+
import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
|
|
7
8
|
import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
|
|
8
9
|
import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
|
|
10
|
+
import { hasNestedQuantifier } from './nested-quantifier'
|
|
11
|
+
import { isRepositoryDirectory } from './repository-directory'
|
|
9
12
|
|
|
10
13
|
export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
|
|
11
14
|
|
|
@@ -89,7 +92,7 @@ const boardSchema = z.strictObject({
|
|
|
89
92
|
pattern: boardPattern.default('board-*'),
|
|
90
93
|
/** The board document inside each of those directories. */
|
|
91
94
|
file: boardFile.default('board.md'),
|
|
92
|
-
/** What the project calls a board: the
|
|
95
|
+
/** What the project calls a board: the herd, its page and the watcher print it as written. */
|
|
93
96
|
term: z.string().min(1).default('board'),
|
|
94
97
|
})
|
|
95
98
|
|
|
@@ -103,6 +106,28 @@ const deploySchema = z.strictObject({
|
|
|
103
106
|
const feedbackSchema = z.strictObject({
|
|
104
107
|
/** GitHub logins whose feedback does not wake the coordinator, such as a status bot; case is ignored. */
|
|
105
108
|
ignore: z.array(z.string().min(1)).default([]),
|
|
109
|
+
/**
|
|
110
|
+
* Texts of a bot's pull request comment that report no finding: such a comment (login ending `[bot]`, no inline
|
|
111
|
+
* comment by the same author within five minutes of it) is no feedback. Compared with whitespace collapsed; the
|
|
112
|
+
* default is the CI code review's comment for a clean run. An empty list counts every bot comment.
|
|
113
|
+
*/
|
|
114
|
+
noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const checksSchema = z.strictObject({
|
|
118
|
+
/**
|
|
119
|
+
* Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
|
|
120
|
+
* prefix: the first group is the name, else the match. The default is Bun's `(fail) <name> [<duration>]`. The
|
|
121
|
+
* watcher runs it on every line of a log it does not control, so a pattern that repeats a group which repeats inside
|
|
122
|
+
* itself (`(a+)+`, `(\w*)*`, and with alternation `(a|aa)+`) is refused: a best-effort scan of the source against
|
|
123
|
+
* catastrophic backtracking, not a proof.
|
|
124
|
+
*/
|
|
125
|
+
failedTestPattern: z
|
|
126
|
+
.string()
|
|
127
|
+
.min(1)
|
|
128
|
+
.refine(isRegularExpression, 'not a valid regular expression')
|
|
129
|
+
.refine((pattern) => !hasNestedQuantifier(pattern), 'a quantified group that holds a quantifier backtracks badly')
|
|
130
|
+
.default(BUN_FAILED_TEST_PATTERN),
|
|
106
131
|
})
|
|
107
132
|
|
|
108
133
|
const previewsSchema = z.strictObject({
|
|
@@ -121,24 +146,33 @@ const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `
|
|
|
121
146
|
|
|
122
147
|
const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
123
148
|
|
|
124
|
-
/** The
|
|
149
|
+
/** The seven known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
|
|
125
150
|
const sourceMapSchema = z
|
|
126
151
|
.object({
|
|
127
152
|
[SOURCE_FIELD.id]: sourcePath,
|
|
128
|
-
/**
|
|
129
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Changes whenever the entry changes; compared as text, so any stable form of a time or version works. Needed
|
|
155
|
+
* only where no content field and no `reportOn` can tell a change.
|
|
156
|
+
*/
|
|
157
|
+
[SOURCE_FIELD.updatedAt]: sourcePath.optional(),
|
|
130
158
|
[SOURCE_FIELD.title]: sourcePath.optional(),
|
|
131
159
|
/** Shown only as an `https:` URL without user name or password. */
|
|
132
160
|
[SOURCE_FIELD.url]: sourcePath.optional(),
|
|
133
161
|
/** The first line with text is shown. */
|
|
134
162
|
[SOURCE_FIELD.body]: sourcePath.optional(),
|
|
135
163
|
[SOURCE_FIELD.author]: sourcePath.optional(),
|
|
164
|
+
/** A ticket source's status, shown on its badge in the herd; also a field `reportOn` can name. */
|
|
165
|
+
[SOURCE_FIELD.status]: sourcePath.optional(),
|
|
136
166
|
})
|
|
137
167
|
.catchall(sourcePath)
|
|
138
168
|
.refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
|
|
139
169
|
message: 'a map field is a letter, then letters, digits and `_`',
|
|
140
170
|
})
|
|
141
171
|
|
|
172
|
+
/** Whether an entry of the source can change at all: by `updatedAt`, a content field or a `reportOn` field. */
|
|
173
|
+
const canChange = ({ map, reportOn }: { map: Record<string, string | undefined>; reportOn?: string[] }): boolean =>
|
|
174
|
+
map.updatedAt !== undefined || CONTENT_FIELDS.some((field) => map[field] !== undefined) || reportOn !== undefined
|
|
175
|
+
|
|
142
176
|
const sourceSchema = z
|
|
143
177
|
.strictObject({
|
|
144
178
|
/** Printed in every report of the source and the key of its saved state. */
|
|
@@ -156,12 +190,21 @@ const sourceSchema = z
|
|
|
156
190
|
reportLimit: z.number().int().min(1).default(5),
|
|
157
191
|
/** The lane the source's reports name. */
|
|
158
192
|
lane: z.string().min(1).optional(),
|
|
193
|
+
/**
|
|
194
|
+
* Marks the entries as tickets: the herd shows each as a badge beside the plan of the lane it belongs to,
|
|
195
|
+
* the lane above or the lane whose board row or plan names its id.
|
|
196
|
+
*/
|
|
197
|
+
ticket: z.boolean().optional(),
|
|
159
198
|
/**
|
|
160
199
|
* The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
|
|
161
200
|
* `author`, `body` and `url`; without it a change of that content reports.
|
|
162
201
|
*/
|
|
163
202
|
reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
|
|
164
203
|
})
|
|
204
|
+
.refine(canChange, {
|
|
205
|
+
message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
|
|
206
|
+
path: ['map'],
|
|
207
|
+
})
|
|
165
208
|
.refine((source) => source.ignoreAuthors.length === 0 || source.map.author !== undefined, {
|
|
166
209
|
message: 'ignoreAuthors needs map.author',
|
|
167
210
|
path: ['ignoreAuthors'],
|
|
@@ -232,6 +275,12 @@ const guardSchema = z.strictObject({
|
|
|
232
275
|
approvalMinutes: z.number().int().min(1).max(1440).default(180),
|
|
233
276
|
})
|
|
234
277
|
.prefault({}),
|
|
278
|
+
approvals: z
|
|
279
|
+
.strictObject({
|
|
280
|
+
/** How long the approval gate hook holds a lane's call for the coordinator's dialog before it denies it; below the hook's 10 minutes. */
|
|
281
|
+
waitMinutes: z.number().int().min(1).max(9).default(8),
|
|
282
|
+
})
|
|
283
|
+
.prefault({}),
|
|
235
284
|
})
|
|
236
285
|
|
|
237
286
|
const hintsSchema = z.strictObject({
|
|
@@ -267,7 +316,11 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
267
316
|
sprintPattern: z
|
|
268
317
|
.never({ error: 'sprintPattern was replaced by board.pattern (with board.file and board.term)' })
|
|
269
318
|
.optional(),
|
|
270
|
-
worktreeDirectory: z
|
|
319
|
+
worktreeDirectory: z
|
|
320
|
+
.string()
|
|
321
|
+
.min(1)
|
|
322
|
+
.refine(isRepositoryDirectory, 'must lie below the repository root: relative, not `.`, without `..`')
|
|
323
|
+
.default(claudePath(WORKTREES_DIRECTORY)),
|
|
271
324
|
baseBranch: branchName.default(DEFAULT_BASE_BRANCH),
|
|
272
325
|
/**
|
|
273
326
|
* Put before every skill and agent name. `jh sync --global` refuses to run without one: a personal skill
|
|
@@ -304,7 +357,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
304
357
|
.prefault({}),
|
|
305
358
|
plans: z
|
|
306
359
|
.strictObject({
|
|
307
|
-
/** Minutes between background fetches of the base branch while `jh
|
|
360
|
+
/** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
|
|
308
361
|
fetchIntervalMinutes: z.number().min(0).default(5),
|
|
309
362
|
/** Links in a plan that the sprint page shows as chips; the list replaces the built-in kinds. */
|
|
310
363
|
linkKinds: z
|
|
@@ -322,13 +375,15 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
322
375
|
/**
|
|
323
376
|
* Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
|
|
324
377
|
* it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
|
|
378
|
+
* `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
|
|
325
379
|
* Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
|
|
326
380
|
* command whose JSON output reports `source` for a new or changed entry.
|
|
327
381
|
*/
|
|
328
382
|
watch: z
|
|
329
383
|
.strictObject({
|
|
330
384
|
deploy: deploySchema.optional(),
|
|
331
|
-
feedback: z.union([z.literal(false), feedbackSchema]).
|
|
385
|
+
feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
|
|
386
|
+
checks: checksSchema.prefault({}),
|
|
332
387
|
previews: previewsSchema.optional(),
|
|
333
388
|
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
334
389
|
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { changedOpenLaneRows } from './lanes'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The longest phases cell, in characters, that still reads as a phase and its state. Longer cells in the open
|
|
6
|
+
* lanes of the real boards carry prose, which belongs in the plan.
|
|
7
|
+
*/
|
|
8
|
+
export const PHASE_CELL_LIMIT = 250
|
|
9
|
+
|
|
10
|
+
export const PROSE_REASON = { long: 'long', sideOrder: 'side-order' } as const
|
|
11
|
+
export type ProseReason = (typeof PROSE_REASON)[keyof typeof PROSE_REASON]
|
|
12
|
+
|
|
13
|
+
/** A changed row of an open lane (1-based line) whose phases cell is `length` characters and carries prose. */
|
|
14
|
+
export type RowWithProse = { line: number; lane: string; reason: ProseReason; length: number }
|
|
15
|
+
|
|
16
|
+
const reasonOf = (phases: string, marker: string): ProseReason | null => {
|
|
17
|
+
if (phases.includes(marker)) return PROSE_REASON.sideOrder
|
|
18
|
+
return phases.length > PHASE_CELL_LIMIT ? PROSE_REASON.long : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The changed rows of open lanes whose phases cell is longer than `PHASE_CELL_LIMIT` or records a side order of the
|
|
23
|
+
* user in prose (`dazu (User …`): the board row names the phase, the side order is a task in the plan's phase.
|
|
24
|
+
* A cell that is both counts once, as a side order.
|
|
25
|
+
*/
|
|
26
|
+
export const boardRowsWithProse = (
|
|
27
|
+
markdown: string,
|
|
28
|
+
language: PlanLanguage,
|
|
29
|
+
previousLines: ReadonlySet<string>,
|
|
30
|
+
): RowWithProse[] => {
|
|
31
|
+
const { sideOrderMarker } = PLAN_VOCABULARY[language]
|
|
32
|
+
return changedOpenLaneRows(markdown, language, previousLines).flatMap(({ line, lane, phases }) => {
|
|
33
|
+
const reason = reasonOf(phases, sideOrderMarker)
|
|
34
|
+
return reason === null ? [] : [{ line, lane, reason, length: phases.length }]
|
|
35
|
+
})
|
|
36
|
+
}
|