jackherd 1.7.0 → 1.8.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 +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { sectionStart } from '../plan-format/markdown'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
4
|
+
|
|
5
|
+
/** One settled question as the board records it; `time` is when the coordinator settled it. */
|
|
6
|
+
export type SettledEntry = {
|
|
7
|
+
time: Date
|
|
8
|
+
lane: string
|
|
9
|
+
question: string
|
|
10
|
+
answer: string
|
|
11
|
+
evidence: string
|
|
12
|
+
/** The state of the cited file: a short commit, or `uncommitted` when the checkout differs from it. */
|
|
13
|
+
evidenceVersion: string
|
|
14
|
+
wayBack: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A local time split into the parts the board writes, each already two digits except the year. */
|
|
18
|
+
type TimeParts = { year: number; month: string; day: string; clock: string }
|
|
19
|
+
|
|
20
|
+
type BoardTexts = { heading: string; header: readonly string[]; notSent: string; time: (parts: TimeParts) => string }
|
|
21
|
+
|
|
22
|
+
const BOARD_TEXTS: Record<PlanLanguage, BoardTexts> = {
|
|
23
|
+
[PLAN_LANGUAGE.de]: {
|
|
24
|
+
heading: 'Selbst beantwortet',
|
|
25
|
+
header: ['Zeit', 'Spur', 'Frage (ein Satz)', 'Antwort', 'Beleg', 'Rückweg'],
|
|
26
|
+
notSent: 'nicht gesendet',
|
|
27
|
+
time: ({ day, month, clock }) => `${day}.${month}. ${clock}`,
|
|
28
|
+
},
|
|
29
|
+
[PLAN_LANGUAGE.en]: {
|
|
30
|
+
heading: 'Settled',
|
|
31
|
+
header: ['Time', 'Lane', 'Question (one sentence)', 'Answer', 'Evidence', 'Way back'],
|
|
32
|
+
notSent: 'not sent',
|
|
33
|
+
time: ({ year, month, day, clock }) => `${year}-${month}-${day} ${clock}`,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NO_WAY_BACK = '–'
|
|
38
|
+
const SECTION_HEADING = /^##\s/
|
|
39
|
+
|
|
40
|
+
/** The heading of the board section, in the board's language. */
|
|
41
|
+
const settledHeading = (language: PlanLanguage): string => BOARD_TEXTS[language].heading
|
|
42
|
+
|
|
43
|
+
/** Whether the line at `index` lies in a settled section, in any language: the nearest `## ` heading above names it. */
|
|
44
|
+
export const isInSettledSection = (lines: readonly string[], index: number): boolean => {
|
|
45
|
+
const headings = Object.values(BOARD_TEXTS).map(({ heading }) => `## ${heading}`)
|
|
46
|
+
const above = lines
|
|
47
|
+
.slice(0, index + 1)
|
|
48
|
+
.reverse()
|
|
49
|
+
.find((line) => SECTION_HEADING.test(line))
|
|
50
|
+
return above !== undefined && headings.includes(above.trim())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
54
|
+
|
|
55
|
+
/** Local time as the board writes it: `DD.MM. hh:mm` in German, `YYYY-MM-DD hh:mm` in English. */
|
|
56
|
+
export const settledTime = (time: Date, language: PlanLanguage): string =>
|
|
57
|
+
BOARD_TEXTS[language].time({
|
|
58
|
+
year: time.getFullYear(),
|
|
59
|
+
month: twoDigits(time.getMonth() + 1),
|
|
60
|
+
day: twoDigits(time.getDate()),
|
|
61
|
+
clock: `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}`,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** Text that stays inside one table cell: on one line, with its pipes escaped. */
|
|
65
|
+
const cell = (text: string): string => terminalSafe(text.replace(/\s+/g, ' ')).trim().replaceAll('|', '\\|')
|
|
66
|
+
|
|
67
|
+
const tableLine = (cells: readonly string[]): string => `| ${cells.join(' | ')} |`
|
|
68
|
+
|
|
69
|
+
/** The row: time, lane, question, answer, evidence, way back; a missing way back reads as a dash. */
|
|
70
|
+
export const settledRow = (entry: SettledEntry, language: PlanLanguage): string =>
|
|
71
|
+
tableLine([
|
|
72
|
+
settledTime(entry.time, language),
|
|
73
|
+
`\`${cell(entry.lane)}\``,
|
|
74
|
+
cell(entry.question),
|
|
75
|
+
cell(entry.answer),
|
|
76
|
+
`\`${cell(`${entry.evidence}@${entry.evidenceVersion}`)}\``,
|
|
77
|
+
entry.wayBack === null ? NO_WAY_BACK : cell(entry.wayBack),
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
/** The entry with the failed send noted after its answer, in the board's language. */
|
|
81
|
+
export const withSendFailure = (entry: SettledEntry, error: string, language: PlanLanguage): SettledEntry => ({
|
|
82
|
+
...entry,
|
|
83
|
+
answer: `${entry.answer} (${BOARD_TEXTS[language].notSent}: ${error})`,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const tableHead = (language: PlanLanguage): string[] => {
|
|
87
|
+
const { header } = BOARD_TEXTS[language]
|
|
88
|
+
return [tableLine(header), `|${header.map((name) => '-'.repeat(name.length + 2)).join('|')}|`]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const withoutTrailingBlanks = (lines: readonly string[]): string[] => {
|
|
92
|
+
let end = lines.length
|
|
93
|
+
while (end > 0 && lines[end - 1].trim() === '') end -= 1
|
|
94
|
+
return lines.slice(0, end)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The board with a new section at its end that holds only the header and the row. */
|
|
98
|
+
const withNewSection = (board: string, row: string, language: PlanLanguage): string => {
|
|
99
|
+
const head = withoutTrailingBlanks(board.split('\n'))
|
|
100
|
+
return [...head, '', `## ${settledHeading(language)}`, '', ...tableHead(language), row, ''].join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Index of the line after the section's first table, or -1 when the section has no table. */
|
|
104
|
+
const afterFirstTable = (lines: readonly string[], start: number, end: number): number => {
|
|
105
|
+
const first = lines.findIndex((line, index) => index >= start && index < end && line.startsWith('|'))
|
|
106
|
+
if (first === -1) return -1
|
|
107
|
+
const after = lines.findIndex((line, index) => index > first && !line.startsWith('|'))
|
|
108
|
+
return after === -1 || after > end ? end : after
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The board with the row appended to the section's table: after its last row, or, in a section without a table,
|
|
113
|
+
* as a new table at the end of the section. A board without the section gets it at its end.
|
|
114
|
+
*/
|
|
115
|
+
export const withSettledRow = (board: string, row: string, language: PlanLanguage): string => {
|
|
116
|
+
const start = sectionStart(board, settledHeading(language))
|
|
117
|
+
if (start === -1) return withNewSection(board, row, language)
|
|
118
|
+
const lines = board.split('\n')
|
|
119
|
+
const next = lines.findIndex((line, index) => index >= start && SECTION_HEADING.test(line))
|
|
120
|
+
const end = next === -1 ? lines.length : next
|
|
121
|
+
const insertAt = afterFirstTable(lines, start, end)
|
|
122
|
+
if (insertAt !== -1) return [...lines.slice(0, insertAt), row, ...lines.slice(insertAt)].join('\n')
|
|
123
|
+
const content = withoutTrailingBlanks(lines.slice(0, end))
|
|
124
|
+
return [...content, '', ...tableHead(language), row, '', ...lines.slice(end)].join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The board with `row` (the last line that equals it) replaced; appended as a new row when it is gone. */
|
|
128
|
+
export const withReplacedRow = (board: string, row: string, replacement: string, language: PlanLanguage): string => {
|
|
129
|
+
const lines = board.split('\n')
|
|
130
|
+
const index = lines.lastIndexOf(row)
|
|
131
|
+
if (index === -1) return withSettledRow(board, replacement, language)
|
|
132
|
+
return [...lines.slice(0, index), replacement, ...lines.slice(index + 1)].join('\n')
|
|
133
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { isAbsolute, posix } from 'node:path'
|
|
2
|
+
import { matchesAnyGlob } from '../check/glob'
|
|
3
|
+
import { isInSettledSection } from './board-row'
|
|
4
|
+
import { ALLOWED_TOPICS, isForbiddenTopic, isSettledTopic, type SettledTopic } from './topics'
|
|
5
|
+
|
|
6
|
+
/** A lane question the coordinator answers itself, as `jh settled` received it; evidence and topic may be absent. */
|
|
7
|
+
export type SettledRequest = {
|
|
8
|
+
lane: string
|
|
9
|
+
question: string
|
|
10
|
+
answer: string
|
|
11
|
+
/** `file:line`, relative to the checkout the command runs in. */
|
|
12
|
+
evidence: string | null
|
|
13
|
+
topic: string | null
|
|
14
|
+
wayBack: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Evidence = { path: string; line: number }
|
|
18
|
+
|
|
19
|
+
/** A request that passed every check: the topic is an allowed one, the evidence a line inside the checkout. */
|
|
20
|
+
export type CheckedRequest = Omit<SettledRequest, 'evidence' | 'topic'> & { evidence: Evidence; topic: SettledTopic }
|
|
21
|
+
|
|
22
|
+
export const REFUSAL = {
|
|
23
|
+
topicMissing: 'topicMissing',
|
|
24
|
+
topicUnknown: 'topicUnknown',
|
|
25
|
+
topicForbidden: 'topicForbidden',
|
|
26
|
+
evidenceMissing: 'evidenceMissing',
|
|
27
|
+
evidenceFormat: 'evidenceFormat',
|
|
28
|
+
evidenceOutside: 'evidenceOutside',
|
|
29
|
+
fileMissing: 'fileMissing',
|
|
30
|
+
lineMissing: 'lineMissing',
|
|
31
|
+
lineWithoutDecision: 'lineWithoutDecision',
|
|
32
|
+
evidenceSelfReference: 'evidenceSelfReference',
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
export type RefusalReason = (typeof REFUSAL)[keyof typeof REFUSAL]
|
|
36
|
+
|
|
37
|
+
export type Refusal = { reason: RefusalReason; detail: string }
|
|
38
|
+
|
|
39
|
+
export type CheckResult = { refusal: Refusal } | { checked: CheckedRequest }
|
|
40
|
+
|
|
41
|
+
/** Reads a file by its path relative to the checkout; null when there is none. */
|
|
42
|
+
export type EvidenceReader = (path: string) => string | null
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Files whose every line is evidence: they are rules an agent follows, written for that purpose. Fixed in the
|
|
46
|
+
* package, not a config key: a project that could widen this list could turn any file into evidence.
|
|
47
|
+
*/
|
|
48
|
+
const RULE_AND_SKILL_FILES: readonly string[] = [
|
|
49
|
+
'.claude/rules/**/*.md',
|
|
50
|
+
'.claude/skills/*/SKILL.md',
|
|
51
|
+
'.claude/project/*.md',
|
|
52
|
+
'CLAUDE.md',
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
const EVIDENCE = /^(.+):([1-9]\d*)$/
|
|
56
|
+
const DATE = /\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}\.\d{1,2}\./
|
|
57
|
+
const USER = /\bUser\b/
|
|
58
|
+
|
|
59
|
+
/** `file:line` split at the last colon; null when the line is not a positive whole number. */
|
|
60
|
+
export const parseEvidence = (text: string): Evidence | null => {
|
|
61
|
+
const match = EVIDENCE.exec(text.trim())
|
|
62
|
+
return match ? { path: match[1], line: Number(match[2]) } : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const isRuleOrSkillFile = (path: string): boolean => matchesAnyGlob(path, RULE_AND_SKILL_FILES)
|
|
66
|
+
|
|
67
|
+
/** A line that records a decision of the user: a date and the word `User`, as plans and boards write it. */
|
|
68
|
+
export const carriesUserDecision = (line: string): boolean => DATE.test(line) && USER.test(line)
|
|
69
|
+
|
|
70
|
+
/** The path normalised inside the checkout; null when it is absolute or leaves the checkout. */
|
|
71
|
+
const insideCheckout = (path: string): string | null => {
|
|
72
|
+
if (isAbsolute(path)) return null
|
|
73
|
+
const normalised = posix.normalize(path)
|
|
74
|
+
return normalised === '..' || normalised.startsWith('../') ? null : normalised
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const topicCheck = (topic: string | null): Refusal | SettledTopic => {
|
|
78
|
+
if (topic === null)
|
|
79
|
+
return { reason: REFUSAL.topicMissing, detail: `--topic is required: ${ALLOWED_TOPICS.join(', ')}` }
|
|
80
|
+
if (!isSettledTopic(topic))
|
|
81
|
+
return { reason: REFUSAL.topicUnknown, detail: `unknown topic ${topic}; one of ${ALLOWED_TOPICS.join(', ')}` }
|
|
82
|
+
return isForbiddenTopic(topic)
|
|
83
|
+
? { reason: REFUSAL.topicForbidden, detail: `${topic} is the user's decision, never settled in a lane's place` }
|
|
84
|
+
: topic
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const lineRefusal = (evidence: Evidence, text: string): Refusal | null => {
|
|
88
|
+
const lines = text.split('\n')
|
|
89
|
+
if (evidence.line > lines.length)
|
|
90
|
+
return { reason: REFUSAL.lineMissing, detail: `${evidence.path} has ${lines.length} lines, not ${evidence.line}` }
|
|
91
|
+
if (isInSettledSection(lines, evidence.line - 1))
|
|
92
|
+
return {
|
|
93
|
+
reason: REFUSAL.evidenceSelfReference,
|
|
94
|
+
detail: `${evidence.path}:${evidence.line} is a settled answer itself, not the decision it rests on`,
|
|
95
|
+
}
|
|
96
|
+
if (isRuleOrSkillFile(evidence.path) || carriesUserDecision(lines[evidence.line - 1])) return null
|
|
97
|
+
return {
|
|
98
|
+
reason: REFUSAL.lineWithoutDecision,
|
|
99
|
+
detail: `${evidence.path}:${evidence.line} carries no date with "User" and is no rule or skill file`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const evidenceCheck = (text: string | null, read: EvidenceReader): Refusal | Evidence => {
|
|
104
|
+
if (text === null) return { reason: REFUSAL.evidenceMissing, detail: '--evidence <file:line> is required' }
|
|
105
|
+
const parsed = parseEvidence(text)
|
|
106
|
+
if (!parsed) return { reason: REFUSAL.evidenceFormat, detail: `${text} is not <file>:<line>` }
|
|
107
|
+
const path = insideCheckout(parsed.path)
|
|
108
|
+
if (path === null) return { reason: REFUSAL.evidenceOutside, detail: `${parsed.path} lies outside the checkout` }
|
|
109
|
+
const content = read(path)
|
|
110
|
+
if (content === null) return { reason: REFUSAL.fileMissing, detail: `${path} does not exist` }
|
|
111
|
+
const evidence = { path, line: parsed.line }
|
|
112
|
+
return lineRefusal(evidence, content) ?? evidence
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isRefusal = (value: Refusal | Evidence | SettledTopic): value is Refusal =>
|
|
116
|
+
typeof value === 'object' && 'reason' in value
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The request ready to settle, or why the coordinator may not settle it itself. The topic is checked first, so a
|
|
120
|
+
* forbidden topic is refused before any file is read; then the evidence: the file exists in the checkout, the line
|
|
121
|
+
* exists, and it records a dated decision of the user unless the file is a rule or skill file.
|
|
122
|
+
*/
|
|
123
|
+
export const checkSettled = (request: SettledRequest, read: EvidenceReader): CheckResult => {
|
|
124
|
+
const topic = topicCheck(request.topic)
|
|
125
|
+
if (isRefusal(topic)) return { refusal: topic }
|
|
126
|
+
const evidence = evidenceCheck(request.evidence, read)
|
|
127
|
+
if (isRefusal(evidence)) return { refusal: evidence }
|
|
128
|
+
return { checked: { ...request, topic, evidence } }
|
|
129
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { TerminalAdapter } from '../config'
|
|
4
|
+
import { boardFor, foundLanesOf } from '../plans/board-pick'
|
|
5
|
+
import { fileGitFacts, mainCheckout, worktreeRoot } from '../plans/git'
|
|
6
|
+
import { readSettings } from '../plans/settings'
|
|
7
|
+
import { laneStatusSourceFor } from '../terminal'
|
|
8
|
+
import { runHerdr } from '../terminal/herdr'
|
|
9
|
+
import { evidenceVersionOf } from './evidence-version'
|
|
10
|
+
import { parseSettledOptions } from './options'
|
|
11
|
+
import { laneSenderFor } from './send'
|
|
12
|
+
import { type SettleOutcome, type SettlePorts, settle } from './settle'
|
|
13
|
+
|
|
14
|
+
const readFileIn =
|
|
15
|
+
(root: string) =>
|
|
16
|
+
(path: string): string | null => {
|
|
17
|
+
const file = join(root, path)
|
|
18
|
+
return existsSync(file) && statSync(file).isFile() ? readFileSync(file, 'utf8') : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const evidenceVersionIn =
|
|
22
|
+
(root: string) =>
|
|
23
|
+
(path: string): string =>
|
|
24
|
+
evidenceVersionOf(fileGitFacts(root, path))
|
|
25
|
+
|
|
26
|
+
const portsFor = (checkout: string, boardFile: string, adapter: TerminalAdapter): SettlePorts => ({
|
|
27
|
+
readEvidence: readFileIn(checkout),
|
|
28
|
+
readBoard: () => readFileSync(boardFile, 'utf8'),
|
|
29
|
+
writeBoard: (text) => writeFileSync(boardFile, text),
|
|
30
|
+
send: laneSenderFor(adapter, runHerdr),
|
|
31
|
+
evidenceVersion: evidenceVersionIn(checkout),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const printOutcome = (outcome: SettleOutcome): void => {
|
|
35
|
+
for (const line of outcome.stdout) console.log(line)
|
|
36
|
+
for (const line of outcome.stderr) console.error(line)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `jh settled <lane> --question … --answer … --evidence <file:line> --topic <topic> [--way-back …] [--board …]`:
|
|
41
|
+
* the one way for the coordinator to answer a lane's question itself. Checks topic and evidence (in the checkout it
|
|
42
|
+
* runs in), appends the row to the board's settled section, sends the answer through `herdr agent prompt` and
|
|
43
|
+
* prints the line for the user's chat. Exit 0 settled, 1 refused or failed before anything was written, 2 written
|
|
44
|
+
* but not sent.
|
|
45
|
+
*/
|
|
46
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
47
|
+
try {
|
|
48
|
+
const { request, boardPath } = parseSettledOptions(args)
|
|
49
|
+
const checkout = worktreeRoot(process.cwd())
|
|
50
|
+
const root = mainCheckout(process.cwd())
|
|
51
|
+
const settings = readSettings([checkout, root])
|
|
52
|
+
const terminal = laneStatusSourceFor(settings.adapter)
|
|
53
|
+
const board = boardFor(boardPath, [root, checkout], settings, () =>
|
|
54
|
+
foundLanesOf(root, settings, () => terminal.listAgents().map((agent) => agent.name)),
|
|
55
|
+
)
|
|
56
|
+
if (board.notice) console.error(board.notice)
|
|
57
|
+
const context = { now: new Date(), planLanguage: settings.planLanguage }
|
|
58
|
+
const outcome = settle(request, context, portsFor(checkout, board.file, settings.adapter))
|
|
59
|
+
printOutcome(outcome)
|
|
60
|
+
return outcome.exitCode
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
63
|
+
return 1
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FileGitFacts } from '../plans/git'
|
|
2
|
+
|
|
3
|
+
/** What the evidence cell says when the cited file is not as a commit holds it. */
|
|
4
|
+
export const EVIDENCE_UNCOMMITTED = 'uncommitted'
|
|
5
|
+
|
|
6
|
+
const SHORT_COMMIT = /^[0-9a-f]{7,64}$/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The state of the evidence a settled row cites: the short commit that last changed the file, so the row can be
|
|
10
|
+
* checked against that version later, or `uncommitted` when the checkout differs from it or git cannot say
|
|
11
|
+
* (`status --porcelain` of the file and its last short commit, null when git failed).
|
|
12
|
+
*/
|
|
13
|
+
export const evidenceVersionOf = ({ status, commit }: FileGitFacts): string =>
|
|
14
|
+
status === '' && commit !== null && SHORT_COMMIT.test(commit) ? commit : EVIDENCE_UNCOMMITTED
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import type { SettledRequest } from './check'
|
|
3
|
+
|
|
4
|
+
/** The request to settle and the board `--board` names, null to let the board be picked. */
|
|
5
|
+
export type SettledOptions = { request: SettledRequest; boardPath: string | null }
|
|
6
|
+
|
|
7
|
+
const VALUE_OPTIONS = ['--question', '--answer', '--evidence', '--topic', '--way-back', '--board'] as const
|
|
8
|
+
|
|
9
|
+
type ValueOption = (typeof VALUE_OPTIONS)[number]
|
|
10
|
+
|
|
11
|
+
const LANE_NAME = /^[A-Za-z0-9._-]+$/
|
|
12
|
+
|
|
13
|
+
const USAGE = `usage: ${CLI_NAME} settled <lane> --question <sentence> --answer <text> --evidence <file:line> --topic <topic> [--way-back <text>] [--board <board.md>]`
|
|
14
|
+
|
|
15
|
+
const usageError = (message: string): Error => new Error(`${message}\n${USAGE}`)
|
|
16
|
+
|
|
17
|
+
const isValueOption = (arg: string): arg is ValueOption => (VALUE_OPTIONS as readonly string[]).includes(arg)
|
|
18
|
+
|
|
19
|
+
/** Splits the arguments into option values and the remaining words; throws on an unknown option or a missing value. */
|
|
20
|
+
const splitArguments = (args: readonly string[]): { values: Map<ValueOption, string>; words: string[] } => {
|
|
21
|
+
const values = new Map<ValueOption, string>()
|
|
22
|
+
const words: string[] = []
|
|
23
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
24
|
+
const arg = args[i]
|
|
25
|
+
if (!arg.startsWith('--')) {
|
|
26
|
+
words.push(arg)
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (!isValueOption(arg)) throw usageError(`unknown option ${arg}`)
|
|
30
|
+
const value = args[i + 1]
|
|
31
|
+
if (value === undefined || value.startsWith('--')) throw usageError(`${arg} needs a value`)
|
|
32
|
+
values.set(arg, value)
|
|
33
|
+
i += 1
|
|
34
|
+
}
|
|
35
|
+
return { values, words }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const requiredText = (values: Map<ValueOption, string>, option: ValueOption): string => {
|
|
39
|
+
const value = values.get(option)?.trim()
|
|
40
|
+
if (!value) throw usageError(`${option} is required`)
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const laneOf = (words: readonly string[]): string => {
|
|
45
|
+
if (words.length === 0) throw usageError('the lane is required')
|
|
46
|
+
if (words.length > 1) throw usageError(`one lane at most, got ${words.join(' ')}`)
|
|
47
|
+
if (!LANE_NAME.test(words[0])) throw usageError(`${words[0]} is no lane name (letters, digits, . _ -)`)
|
|
48
|
+
return words[0]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads `<lane> --question … --answer … [--evidence …] [--topic …] [--way-back …] [--board …]` in any order. Lane,
|
|
53
|
+
* question and answer are required here; evidence and topic stay null when absent, so the check refuses them with
|
|
54
|
+
* its reason.
|
|
55
|
+
*/
|
|
56
|
+
export const parseSettledOptions = (args: readonly string[]): SettledOptions => {
|
|
57
|
+
const { values, words } = splitArguments(args)
|
|
58
|
+
return {
|
|
59
|
+
request: {
|
|
60
|
+
lane: laneOf(words),
|
|
61
|
+
question: requiredText(values, '--question'),
|
|
62
|
+
answer: requiredText(values, '--answer'),
|
|
63
|
+
evidence: values.get('--evidence') ?? null,
|
|
64
|
+
topic: values.get('--topic') ?? null,
|
|
65
|
+
wayBack: values.get('--way-back') ?? null,
|
|
66
|
+
},
|
|
67
|
+
boardPath: values.get('--board') ?? null,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
|
+
import { errorText } from '../plans/error-text'
|
|
3
|
+
import type { HerdrRunner } from '../terminal/herdr'
|
|
4
|
+
|
|
5
|
+
/** Hands a text to a lane's agent; returns the error text, or null when the agent took it. */
|
|
6
|
+
export type LaneSender = (lane: string, message: string) => string | null
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `herdr agent prompt <lane> <text>`, the way the coordinator prompts a lane: the text as one argument of the
|
|
10
|
+
* process, so no shell expands its backticks or `$(…)`.
|
|
11
|
+
*/
|
|
12
|
+
export const promptArguments = (lane: string, message: string): string[] => ['agent', 'prompt', lane, message]
|
|
13
|
+
|
|
14
|
+
const NO_TERMINAL = `no terminal to reach the lane (terminal.adapter is ${TERMINAL_ADAPTER.none})`
|
|
15
|
+
|
|
16
|
+
/** The sender for the configured terminal; without one (`none`) every send fails with the reason. */
|
|
17
|
+
export const laneSenderFor =
|
|
18
|
+
(adapter: TerminalAdapter, run: HerdrRunner): LaneSender =>
|
|
19
|
+
(lane, message) => {
|
|
20
|
+
if (adapter === TERMINAL_ADAPTER.none) return NO_TERMINAL
|
|
21
|
+
try {
|
|
22
|
+
run(promptArguments(lane, message))
|
|
23
|
+
return null
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return errorText(error)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { errorText } from '../plans/error-text'
|
|
2
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { type SettledEntry, settledRow, withReplacedRow, withSendFailure, withSettledRow } from './board-row'
|
|
4
|
+
import { type CheckedRequest, checkSettled, type Evidence, type EvidenceReader, type SettledRequest } from './check'
|
|
5
|
+
import type { LaneSender } from './send'
|
|
6
|
+
|
|
7
|
+
/** The I/O `settle` drives. */
|
|
8
|
+
export type SettlePorts = {
|
|
9
|
+
readEvidence: EvidenceReader
|
|
10
|
+
readBoard: () => string
|
|
11
|
+
writeBoard: (text: string) => void
|
|
12
|
+
send: LaneSender
|
|
13
|
+
/** The state of the cited file: a short commit, or `uncommitted`. */
|
|
14
|
+
evidenceVersion: (path: string) => string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** When it runs and in which language the board is written; the lane is told in English. */
|
|
18
|
+
export type SettleContext = { now: Date; planLanguage: PlanLanguage }
|
|
19
|
+
|
|
20
|
+
export const SETTLE_EXIT = { settled: 0, refused: 1, notSent: 2 } as const
|
|
21
|
+
|
|
22
|
+
export type SettleExit = (typeof SETTLE_EXIT)[keyof typeof SETTLE_EXIT]
|
|
23
|
+
|
|
24
|
+
export type SettleOutcome = { exitCode: SettleExit; stdout: string[]; stderr: string[] }
|
|
25
|
+
|
|
26
|
+
const evidenceText = ({ path, line }: Evidence): string => `${path}:${line}`
|
|
27
|
+
|
|
28
|
+
/** What the lane receives: its question, the answer and the evidence, as written, in English words around them. */
|
|
29
|
+
export const laneMessageOf = ({ question, answer, evidence }: CheckedRequest): string =>
|
|
30
|
+
`The coordinator’s answer to your question “${question}”: ${answer} (evidence: ${evidenceText(evidence)})`
|
|
31
|
+
|
|
32
|
+
const entryOf = (request: CheckedRequest, now: Date, evidenceVersion: string): SettledEntry => ({
|
|
33
|
+
time: now,
|
|
34
|
+
lane: request.lane,
|
|
35
|
+
question: request.question,
|
|
36
|
+
answer: request.answer,
|
|
37
|
+
evidence: evidenceText(request.evidence),
|
|
38
|
+
evidenceVersion,
|
|
39
|
+
wayBack: request.wayBack,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** The line for the user's chat, which the coordinator posts as it stands. */
|
|
43
|
+
const chatLine = (request: CheckedRequest): string =>
|
|
44
|
+
`settled ${request.lane}: ${request.question} → ${request.answer} (evidence ${evidenceText(request.evidence)})`
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Notes the failed send in the row the board holds now, so rows written meanwhile stay. Returns why the note could
|
|
48
|
+
* not be written, or null; the send has failed either way, so a board error must not turn exit 2 into exit 1.
|
|
49
|
+
*/
|
|
50
|
+
const noteFailure = (entry: SettledEntry, error: string, context: SettleContext, ports: SettlePorts): string | null => {
|
|
51
|
+
const row = settledRow(entry, context.planLanguage)
|
|
52
|
+
const noted = settledRow(withSendFailure(entry, error, context.planLanguage), context.planLanguage)
|
|
53
|
+
try {
|
|
54
|
+
ports.writeBoard(withReplacedRow(ports.readBoard(), row, noted, context.planLanguage))
|
|
55
|
+
return null
|
|
56
|
+
} catch (noteError) {
|
|
57
|
+
return errorText(noteError)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const notSent = (request: CheckedRequest, error: string, noteError: string | null): SettleOutcome => ({
|
|
62
|
+
exitCode: SETTLE_EXIT.notSent,
|
|
63
|
+
stdout: [chatLine(request)],
|
|
64
|
+
stderr:
|
|
65
|
+
noteError === null
|
|
66
|
+
? [`not sent to ${request.lane}: ${error}; the board row says so`]
|
|
67
|
+
: [`not sent to ${request.lane}: ${error}`, `board row not annotated: ${noteError}`],
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Settles a lane question in an order that never does half of it: check topic and evidence, then write the board
|
|
72
|
+
* row, then send the answer to the lane. A refusal touches neither board nor lane. A send that fails after the row
|
|
73
|
+
* was written is said on stderr and noted in that row, so the board never claims an answer the lane did not get.
|
|
74
|
+
*/
|
|
75
|
+
export const settle = (request: SettledRequest, context: SettleContext, ports: SettlePorts): SettleOutcome => {
|
|
76
|
+
const result = checkSettled(request, ports.readEvidence)
|
|
77
|
+
if ('refusal' in result) {
|
|
78
|
+
const { reason, detail } = result.refusal
|
|
79
|
+
return { exitCode: SETTLE_EXIT.refused, stdout: [], stderr: [`refused (${reason}): ${detail}`] }
|
|
80
|
+
}
|
|
81
|
+
const { checked } = result
|
|
82
|
+
const entry = entryOf(checked, context.now, ports.evidenceVersion(checked.evidence.path))
|
|
83
|
+
ports.writeBoard(withSettledRow(ports.readBoard(), settledRow(entry, context.planLanguage), context.planLanguage))
|
|
84
|
+
const error = ports.send(checked.lane, laneMessageOf(checked))
|
|
85
|
+
if (error === null) return { exitCode: SETTLE_EXIT.settled, stdout: [chatLine(checked)], stderr: [] }
|
|
86
|
+
return notSent(checked, error, noteFailure(entry, error, context, ports))
|
|
87
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a question the coordinator settles itself is about. A fixed vocabulary in the package, not a config value:
|
|
3
|
+
* a project that could add topics could also drop a forbidden one. The forbidden ones are part of the vocabulary so
|
|
4
|
+
* a refusal can name them instead of calling them unknown.
|
|
5
|
+
*/
|
|
6
|
+
export const SETTLED_TOPIC = {
|
|
7
|
+
scope: 'scope',
|
|
8
|
+
design: 'design',
|
|
9
|
+
followUp: 'follow-up',
|
|
10
|
+
naming: 'naming',
|
|
11
|
+
codeStyle: 'code-style',
|
|
12
|
+
test: 'test',
|
|
13
|
+
documentation: 'documentation',
|
|
14
|
+
board: 'board',
|
|
15
|
+
dependency: 'dependency',
|
|
16
|
+
merge: 'merge',
|
|
17
|
+
release: 'release',
|
|
18
|
+
publish: 'publish',
|
|
19
|
+
migration: 'migration',
|
|
20
|
+
money: 'money',
|
|
21
|
+
auth: 'auth',
|
|
22
|
+
rls: 'rls',
|
|
23
|
+
secrets: 'secrets',
|
|
24
|
+
stageWrite: 'stage-write',
|
|
25
|
+
productionWrite: 'production-write',
|
|
26
|
+
deletion: 'deletion',
|
|
27
|
+
push: 'push',
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
export type SettledTopic = (typeof SETTLED_TOPIC)[keyof typeof SETTLED_TOPIC]
|
|
31
|
+
|
|
32
|
+
/** Topics the user decides every time; the coordinator never answers them in a lane's place. */
|
|
33
|
+
export const FORBIDDEN_TOPICS: readonly SettledTopic[] = [
|
|
34
|
+
SETTLED_TOPIC.dependency,
|
|
35
|
+
SETTLED_TOPIC.merge,
|
|
36
|
+
SETTLED_TOPIC.release,
|
|
37
|
+
SETTLED_TOPIC.publish,
|
|
38
|
+
SETTLED_TOPIC.migration,
|
|
39
|
+
SETTLED_TOPIC.money,
|
|
40
|
+
SETTLED_TOPIC.auth,
|
|
41
|
+
SETTLED_TOPIC.rls,
|
|
42
|
+
SETTLED_TOPIC.secrets,
|
|
43
|
+
SETTLED_TOPIC.stageWrite,
|
|
44
|
+
SETTLED_TOPIC.productionWrite,
|
|
45
|
+
SETTLED_TOPIC.deletion,
|
|
46
|
+
SETTLED_TOPIC.push,
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
export const isForbiddenTopic = (topic: SettledTopic): boolean => FORBIDDEN_TOPICS.includes(topic)
|
|
50
|
+
|
|
51
|
+
export const ALLOWED_TOPICS: readonly SettledTopic[] = Object.values(SETTLED_TOPIC).filter(
|
|
52
|
+
(topic) => !isForbiddenTopic(topic),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
export const isSettledTopic = (value: string): value is SettledTopic =>
|
|
56
|
+
(Object.values(SETTLED_TOPIC) as string[]).includes(value)
|
package/src/sync/command.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { hasProjectInstall } from '../config/legacy-names'
|
|
3
|
+
import { configCandidates } from '../config/location'
|
|
4
|
+
import { CLI_NAME, PACKAGE_NAME } from '../version/release'
|
|
2
5
|
import {
|
|
3
6
|
type CommandContext,
|
|
4
7
|
defaultContext,
|
|
@@ -62,6 +65,16 @@ export const parseSyncArgs = (args: string[]): SyncArgs => {
|
|
|
62
65
|
return { ok: true, accept, global, ifChanged, check }
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* True when `root` has a project config but no project install: the config holds settings for the global install,
|
|
70
|
+
* and only `jh init` may turn it into a project install.
|
|
71
|
+
*/
|
|
72
|
+
const isConfigWithoutInstall = (root: string, exists: (path: string) => boolean): boolean =>
|
|
73
|
+
configCandidates(root).some(exists) && !hasProjectInstall(root, exists)
|
|
74
|
+
|
|
75
|
+
const notInstalledLine = (root: string): string =>
|
|
76
|
+
`${CLI_NAME} sync: ${root} has a ${PACKAGE_NAME} config but no project install, so nothing was synced. \`${CLI_NAME} init\` installs the kit here; \`${CLI_NAME} sync ${GLOBAL_FLAG}\` updates the global kit.`
|
|
77
|
+
|
|
65
78
|
const syncGlobal = (context: CommandContext, accept: string[]): Promise<number> =>
|
|
66
79
|
runWithKit(context, 'sync', async (kit) => {
|
|
67
80
|
const result = await runGlobalSync({ home: context.home, kit, accept, configHome: context.configHome })
|
|
@@ -98,6 +111,8 @@ export const runSyncCommand = async (
|
|
|
98
111
|
const { accept, global, ifChanged, check: dryRun } = parsed
|
|
99
112
|
if (ifChanged) return syncGlobalIfChanged(context, accept, check)
|
|
100
113
|
if (global) return syncGlobal(context, accept)
|
|
114
|
+
if (isConfigWithoutInstall(context.projectRoot, existsSync))
|
|
115
|
+
return usageError(context, notInstalledLine(context.projectRoot))
|
|
101
116
|
return runWithKit(
|
|
102
117
|
context,
|
|
103
118
|
'sync',
|
package/src/sync/render.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { JackherdConfig } from '../config'
|
|
2
2
|
import { frozenInstallCommand } from '../config/detect'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ADDRESS_FORM,
|
|
5
|
+
APPROVAL_TEXTS,
|
|
6
|
+
LANGUAGE_NAME,
|
|
7
|
+
PLAN_APPROVAL_LABELS,
|
|
8
|
+
PLAN_VOCABULARY,
|
|
9
|
+
SELF_MERGE_LABEL,
|
|
10
|
+
} from '../vocabulary'
|
|
4
11
|
|
|
5
12
|
const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
|
|
6
13
|
|
|
@@ -22,7 +29,7 @@ const prefixed = (prefix: string, values: Record<string, string>): [string, stri
|
|
|
22
29
|
|
|
23
30
|
/** Every placeholder a kit file may use, with its value for this project. */
|
|
24
31
|
const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
25
|
-
const { header, sections, columns, phase, laneClosed } = PLAN_VOCABULARY[config.planLanguage]
|
|
32
|
+
const { header, sections, columns, phase, laneClosed, openSubagents } = PLAN_VOCABULARY[config.planLanguage]
|
|
26
33
|
return new Map([
|
|
27
34
|
['kit.responseLanguage', LANGUAGE_NAME[config.responseLanguage]],
|
|
28
35
|
['kit.responseAddress', ADDRESS_FORM[config.responseLanguage][config.responseAddress]],
|
|
@@ -36,9 +43,11 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
36
43
|
['kit.lanes.lockFile', config.lanes.lockFile],
|
|
37
44
|
['kit.lanes.verifyInstall', frozenInstallCommand(config.commands.install)],
|
|
38
45
|
['kit.word.laneClosed', laneClosed],
|
|
46
|
+
['kit.word.openSubagents', openSubagents],
|
|
39
47
|
...prefixed('board', config.board),
|
|
40
48
|
['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
|
|
41
49
|
['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
|
|
50
|
+
...prefixed('guard.planApproval', PLAN_APPROVAL_LABELS[config.responseLanguage]),
|
|
42
51
|
...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
|
|
43
52
|
['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
|
|
44
53
|
...prefixed('heading', { ...header, ...sections, ...columns, phase }),
|
|
@@ -51,8 +60,8 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
51
60
|
|
|
52
61
|
/**
|
|
53
62
|
* Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
|
|
54
|
-
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word
|
|
55
|
-
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
|
|
63
|
+
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
|
|
64
|
+
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.planApproval.<approve|revoke>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
|
|
56
65
|
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. Other
|
|
57
66
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
|
58
67
|
* file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
|