jackherd 1.9.0 → 1.10.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 +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- 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 +52 -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/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -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 +1 -1
- package/schemas/jackherd.schema.json +59 -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 +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- 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/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -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/git.ts +19 -5
- 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 +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- 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/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/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- 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 +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- 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 +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -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 +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- 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/sources.ts +6 -3
- 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
|
@@ -1,76 +1,231 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { fit, wrap } from '../terminal/text'
|
|
1
|
+
import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
|
|
2
|
+
import { fit, padVisible, wrap } from '../terminal/text'
|
|
3
3
|
import { textWidth } from '../terminal/width'
|
|
4
|
+
import { ASK_KIND, type AskTarget } from './ask-target'
|
|
4
5
|
import { plain } from './layout-phase'
|
|
5
6
|
import { MOUSE_BUTTON, type MouseEvent } from './selection'
|
|
7
|
+
import { shortTitle } from './task-name'
|
|
6
8
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* One row of a block of the herd as drawn: its plain text lines (more than one while it is expanded; the first starts
|
|
11
|
+
* with `mark` and a blank and, for a row with `[ask]`, is padded to leave the button its cells), whether its text is wider than the pane (`cut`), whether it is expanded, and the address a
|
|
12
|
+
* click opens.
|
|
13
|
+
*/
|
|
14
|
+
export type BlockRow = {
|
|
15
|
+
key: string
|
|
16
|
+
mark: string
|
|
17
|
+
lines: string[]
|
|
18
|
+
cut: boolean
|
|
19
|
+
expanded: boolean
|
|
20
|
+
url: string | null
|
|
21
|
+
/** What the coordinator can be asked to put: a dialog question or a row of the board; null for files and appointments. */
|
|
22
|
+
ask: AskTarget | null
|
|
23
|
+
/** The screen column (1-based) `[ask]` starts in on the first line, five cells wide; null for a row without one. */
|
|
24
|
+
askColumn: number | null
|
|
25
|
+
/** What the folded block shows of the row: the time column, if any, and the short title, without the mark. */
|
|
26
|
+
preview: string
|
|
27
|
+
/** Per line, the index in it where the part behind the short title starts; the line's length where none of it is. */
|
|
28
|
+
dimFrom: number[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a click does: expand or fold the row (its key), open an address, ask the coordinator to put the row, or nothing (null). */
|
|
32
|
+
export type BlockClick = { toggle: string } | { open: string } | { ask: AskTarget }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One row of a block of the herd before it is drawn: the key it expands by, the `mark` that starts it (kept apart so
|
|
36
|
+
* the block can paint it), its text, and what a click may do.
|
|
37
|
+
*/
|
|
38
|
+
export type BlockItem = {
|
|
39
|
+
key: string
|
|
40
|
+
mark: string
|
|
41
|
+
/** A column before the text, padded by the caller to the widest of the block (the time of an appointment). */
|
|
42
|
+
lead?: string
|
|
43
|
+
text: string
|
|
44
|
+
/** What the row shows while folded; the whole `text` when absent. */
|
|
45
|
+
title?: string
|
|
46
|
+
url: string | null
|
|
47
|
+
ask: AskTarget | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a click on a block is matched against, built from the block as it was drawn: `firstLine` is the screen line
|
|
52
|
+
* (0-based) of the first row, the line above it is the block's head; `rows` and `moreKey` are empty and null while the
|
|
53
|
+
* block is folded; `keys` are all its points, what a fold of the block remembers.
|
|
54
|
+
*/
|
|
55
|
+
export type BlockHit = {
|
|
56
|
+
firstLine: number
|
|
57
|
+
rows: readonly BlockRow[]
|
|
58
|
+
moreKey: string | null
|
|
59
|
+
keys: readonly string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How many rows a block of the herd (for-you, appointments) draws; the rest is one `+<n>` line. */
|
|
63
|
+
export const SHOWN_BLOCK_ROWS = 5
|
|
64
|
+
/** The key in the herd's expanded set that shows every point of the for-you block instead of the first five. */
|
|
65
|
+
export const WAITING_MORE_KEY = 'more>waiting'
|
|
66
|
+
/** The same for the appointment block. */
|
|
67
|
+
export const APPOINTMENT_MORE_KEY = 'more>appointment'
|
|
9
68
|
/** The bar and the blank that start every line of a for-you row. */
|
|
10
69
|
export const WAITING_MARK = '▌'
|
|
11
70
|
|
|
71
|
+
/** The button at the right edge of a question or a board row; a click on it, and only on it, asks the coordinator. */
|
|
72
|
+
export const ASK_BUTTON = '[ask]'
|
|
73
|
+
const ASK_GAP = 1
|
|
12
74
|
const FILE_MARK = '📎'
|
|
13
75
|
const QUESTION_MARK = '?'
|
|
14
76
|
const OPENABLE = /^(https?|file):\/\//
|
|
15
77
|
|
|
16
|
-
/**
|
|
17
|
-
* One for-you row as drawn: its plain text lines (more than one while it is expanded), whether its text is wider than
|
|
18
|
-
* the pane (`cut`), whether it is expanded, and the address a click opens.
|
|
19
|
-
*/
|
|
20
|
-
export type WaitingRow = { key: string; lines: string[]; cut: boolean; expanded: boolean; url: string | null }
|
|
21
|
-
|
|
22
|
-
/** What a click does: expand or fold the row (its key), open an address, or nothing (null). */
|
|
23
|
-
export type WaitingClick = { toggle: string } | { open: string }
|
|
24
|
-
|
|
25
78
|
/** The key of a row in the herd's expanded set: the record's id, else its text. */
|
|
26
79
|
export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
|
|
27
80
|
|
|
28
|
-
|
|
81
|
+
/** The keys of all open points, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
|
|
82
|
+
export const waitingKeys = (items: readonly WaitingItem[]): string[] => items.map(waitingKey)
|
|
29
83
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
84
|
+
const BAR_WIDTH = textWidth(`${WAITING_MARK} `)
|
|
85
|
+
|
|
86
|
+
const roomOf = (width: number): number => Math.max(width - BAR_WIDTH, 1)
|
|
87
|
+
|
|
88
|
+
/** The cells a row's text may take and the screen column `[ask]` starts in; no column for a row without a button. */
|
|
89
|
+
const askLayout = (ask: AskTarget | null, width: number): { room: number; askColumn: number | null } => {
|
|
90
|
+
if (!ask) return { room: roomOf(width), askColumn: null }
|
|
91
|
+
const room = Math.max(roomOf(width) - (textWidth(ASK_BUTTON) + ASK_GAP), 1)
|
|
92
|
+
return { room, askColumn: BAR_WIDTH + room + ASK_GAP + 1 }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const askOf = (item: WaitingItem): AskTarget | null => {
|
|
96
|
+
if (item.kind === WAITING_KIND.file) return null
|
|
97
|
+
if (item.kind === WAITING_KIND.question) return item.id ? { kind: ASK_KIND.question, id: item.id } : null
|
|
98
|
+
return { kind: ASK_KIND.boardRow, since: item.since, item: item.item }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The text wrapped to `room` cells behind `start` (the mark and the time column), the other lines indented to the column of the text. */
|
|
102
|
+
const hangingLines = (start: string, text: string, room: number): string[] => {
|
|
103
|
+
const hang = textWidth(start)
|
|
104
|
+
const [first, ...rest] = wrap(text, Math.max(room - hang, 1))
|
|
105
|
+
return [`${start}${first}`, ...rest.map((line) => `${' '.repeat(hang)}${line}`)]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Per line, the index where the part behind the short title starts (the line's length while none of it is on the
|
|
110
|
+
* line): the title may take several lines, and the rest starts on the line where it ends. `titleLength` is 0 for a
|
|
111
|
+
* row without a short title.
|
|
112
|
+
*/
|
|
113
|
+
const dimStarts = (lines: readonly string[], start: string, titleLength: number): number[] => {
|
|
114
|
+
let remaining = titleLength
|
|
115
|
+
return lines.map((line, index) => {
|
|
116
|
+
if (titleLength === 0) return line.length
|
|
117
|
+
const from = index === 0 ? start.length : textWidth(start)
|
|
118
|
+
const content = line.length - from
|
|
119
|
+
if (remaining > content) {
|
|
120
|
+
remaining -= content + 1
|
|
121
|
+
return line.length
|
|
122
|
+
}
|
|
123
|
+
const dimFrom = from + Math.max(remaining, 0)
|
|
124
|
+
remaining = 0
|
|
125
|
+
return dimFrom
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const itemOf = (item: WaitingItem): BlockItem => ({
|
|
130
|
+
key: waitingKey(item),
|
|
131
|
+
mark: item.kind === WAITING_KIND.file ? FILE_MARK : QUESTION_MARK,
|
|
132
|
+
text: plain(item.item),
|
|
133
|
+
title: shortTitle(plain(item.item)),
|
|
134
|
+
url: item.kind === WAITING_KIND.file && item.url && OPENABLE.test(item.url) ? item.url : null,
|
|
135
|
+
ask: askOf(item),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const rowOf = (
|
|
139
|
+
{ key, mark, lead, text, title = text, url, ask }: BlockItem,
|
|
140
|
+
expandedKeys: ReadonlySet<string>,
|
|
141
|
+
width: number,
|
|
142
|
+
): BlockRow => {
|
|
143
|
+
const { room, askColumn } = askLayout(ask, width)
|
|
144
|
+
const leadText = lead === undefined ? '' : `${lead} `
|
|
145
|
+
const start = `${mark} ${leadText}`
|
|
146
|
+
const cut = title !== text || textWidth(`${start}${text}`) > room
|
|
35
147
|
const expanded = cut && expandedKeys.has(key)
|
|
36
|
-
const
|
|
37
|
-
|
|
148
|
+
const [first, ...rest] = expanded ? hangingLines(start, text, room) : [fit(`${start}${title}`, room)]
|
|
149
|
+
const lines = ask ? [padVisible(first, room + ASK_GAP), ...rest] : [first, ...rest]
|
|
150
|
+
const dimFrom = dimStarts([first, ...rest], start, expanded && title !== text ? title.length : 0)
|
|
151
|
+
return { key, mark, lines, cut, expanded, url, ask, askColumn, preview: `${leadText}${title}`, dimFrom }
|
|
38
152
|
}
|
|
39
153
|
|
|
40
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* The rows a block of the herd draws: the first five, or all of them while `moreKey` is in the expanded set; each
|
|
156
|
+
* folded to one line unless expanded.
|
|
157
|
+
*/
|
|
158
|
+
export const blockRows = (
|
|
159
|
+
items: readonly BlockItem[],
|
|
160
|
+
expandedKeys: ReadonlySet<string>,
|
|
161
|
+
width: number,
|
|
162
|
+
moreKey: string,
|
|
163
|
+
): BlockRow[] =>
|
|
164
|
+
(expandedKeys.has(moreKey) ? items : items.slice(0, SHOWN_BLOCK_ROWS)).map((item) => rowOf(item, expandedKeys, width))
|
|
165
|
+
|
|
166
|
+
/** The rows the herd draws for what waits on the user. */
|
|
41
167
|
export const waitingRows = (
|
|
42
168
|
items: readonly WaitingItem[],
|
|
43
169
|
expandedKeys: ReadonlySet<string>,
|
|
44
170
|
width: number,
|
|
45
|
-
):
|
|
171
|
+
): BlockRow[] => blockRows(items.map(itemOf), expandedKeys, width, WAITING_MORE_KEY)
|
|
172
|
+
|
|
173
|
+
/** The key of the `+<n>` line of a block with `count` points, null while the block has no more than it shows. */
|
|
174
|
+
export const moreKeyOf = (count: number, key: string): string | null => (count > SHOWN_BLOCK_ROWS ? key : null)
|
|
46
175
|
|
|
47
|
-
const rowAt = (
|
|
176
|
+
const rowAt = (
|
|
177
|
+
rows: readonly BlockRow[],
|
|
178
|
+
firstLine: number,
|
|
179
|
+
line: number,
|
|
180
|
+
): { row: BlockRow; onFirstLine: boolean } | null => {
|
|
48
181
|
let first = firstLine
|
|
49
182
|
for (const row of rows) {
|
|
50
|
-
if (line < first + row.lines.length) return line >= first ? row : null
|
|
183
|
+
if (line < first + row.lines.length) return line >= first ? { row, onFirstLine: line === first } : null
|
|
51
184
|
first += row.lines.length
|
|
52
185
|
}
|
|
53
186
|
return null
|
|
54
187
|
}
|
|
55
188
|
|
|
56
|
-
const
|
|
189
|
+
const onAskButton = ({ askColumn }: BlockRow, column: number | undefined): boolean =>
|
|
190
|
+
askColumn !== null && column !== undefined && column >= askColumn && column < askColumn + textWidth(ASK_BUTTON)
|
|
191
|
+
|
|
192
|
+
const clickOn = (row: BlockRow, onFirstLine: boolean, column: number | undefined): BlockClick | null => {
|
|
193
|
+
const { key, cut, expanded, url, ask } = row
|
|
194
|
+
if (ask && onFirstLine && onAskButton(row, column)) return { ask }
|
|
57
195
|
if (url) return cut && !expanded ? { toggle: key } : { open: url }
|
|
58
196
|
return cut ? { toggle: key } : null
|
|
59
197
|
}
|
|
60
198
|
|
|
199
|
+
/** Whether a plain left click hit the head line of a block, which folds and opens it. `headLine` is 0-based. */
|
|
200
|
+
export const blockHeadClick = (
|
|
201
|
+
headLine: number,
|
|
202
|
+
{ button, row, pressed }: Pick<MouseEvent, 'button' | 'row' | 'pressed'>,
|
|
203
|
+
): boolean => pressed && button === MOUSE_BUTTON.left && row - 1 === headLine
|
|
204
|
+
|
|
61
205
|
/**
|
|
62
|
-
* What a plain left click on
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `
|
|
206
|
+
* What a plain left click on a block does. A click on the `[ask]` button of a question or a board row (the columns of
|
|
207
|
+
* its first line) asks the coordinator, whatever the fold. Any other click on a row that is cut off expands or folds it,
|
|
208
|
+
* except that a file or Artifact opens on the click after it expanded; a row that fits opens its file or does nothing.
|
|
209
|
+
* The `+<n>` line, the line right
|
|
210
|
+
* under the rows, toggles `moreKey` (all points or the first five); `moreKey` is null while the block has no such
|
|
211
|
+
* line. Other lines, a click with a modifier (ctrl is left to the terminal's own link opening), a release and the
|
|
212
|
+
* wheel do nothing. `firstLine` is the screen line (0-based) of the first row, as `renderScreen` returns it.
|
|
67
213
|
*/
|
|
68
|
-
export const
|
|
69
|
-
rows: readonly
|
|
214
|
+
export const blockClick = (
|
|
215
|
+
rows: readonly BlockRow[],
|
|
70
216
|
firstLine: number,
|
|
71
|
-
{
|
|
72
|
-
|
|
217
|
+
{
|
|
218
|
+
button,
|
|
219
|
+
row,
|
|
220
|
+
pressed,
|
|
221
|
+
column,
|
|
222
|
+
}: Pick<MouseEvent, 'button' | 'row' | 'pressed'> & Partial<Pick<MouseEvent, 'column'>>,
|
|
223
|
+
moreKey: string | null = null,
|
|
224
|
+
): BlockClick | null => {
|
|
73
225
|
if (!pressed || button !== MOUSE_BUTTON.left) return null
|
|
74
|
-
const
|
|
75
|
-
|
|
226
|
+
const line = row - 1
|
|
227
|
+
const moreLine = firstLine + rows.reduce((sum, { lines }) => sum + lines.length, 0)
|
|
228
|
+
if (moreKey !== null && line === moreLine) return { toggle: moreKey }
|
|
229
|
+
const target = rowAt(rows, firstLine, line)
|
|
230
|
+
return target ? clickOn(target.row, target.onFirstLine, column) : null
|
|
76
231
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { openWaitingItems, type WaitingItem } from '../plan-format/waiting'
|
|
2
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { type QuestionRecord, questionWaitingItems } from './question-records'
|
|
4
|
+
import { readQuestionRecords } from './questions-read'
|
|
5
|
+
import { type SentFileRecord, sentWaitingItems } from './sent-files'
|
|
6
|
+
import { readSentRecords } from './sent-files-read'
|
|
7
|
+
|
|
8
|
+
/** The two logs that wait on the user, as read from the main checkout. */
|
|
9
|
+
export type WaitingRecords = { sent: SentFileRecord[]; questions: QuestionRecord[] }
|
|
10
|
+
|
|
11
|
+
/** The files sent to the user and the questions left unanswered that no one closed, files first. */
|
|
12
|
+
export const waitingItemsOfRecords = (
|
|
13
|
+
sent: readonly SentFileRecord[],
|
|
14
|
+
questions: readonly QuestionRecord[],
|
|
15
|
+
now: number,
|
|
16
|
+
): WaitingItem[] => [...sentWaitingItems(sent, now), ...questionWaitingItems(questions, now)]
|
|
17
|
+
|
|
18
|
+
/** What waits on the user: the board's own rows (none when `board` is null), then the sends and questions. */
|
|
19
|
+
export const waitingItemsOf = (
|
|
20
|
+
board: string | null,
|
|
21
|
+
language: PlanLanguage,
|
|
22
|
+
{ sent, questions }: WaitingRecords,
|
|
23
|
+
now: number,
|
|
24
|
+
): WaitingItem[] => [
|
|
25
|
+
...(board === null ? [] : openWaitingItems(board, language)),
|
|
26
|
+
...waitingItemsOfRecords(sent, questions, now),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** Both logs read from `root`; each file is read once, whoever uses it. */
|
|
30
|
+
export const readWaitingRecords = (root: string): WaitingRecords => ({
|
|
31
|
+
sent: readSentRecords(root),
|
|
32
|
+
questions: readQuestionRecords(root),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/** The files sent to the user and the questions left unanswered that no one closed, files first, read from `root`. */
|
|
36
|
+
export const recordWaitingItems = (root: string, now: number): WaitingItem[] => {
|
|
37
|
+
const { sent, questions } = readWaitingRecords(root)
|
|
38
|
+
return waitingItemsOfRecords(sent, questions, now)
|
|
39
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { DAY_MS } from '../plan-format/clock'
|
|
2
|
+
|
|
3
|
+
/** A question or a sent file waits on the user this long at most; a later answer or `jh waiting close` ends it earlier. */
|
|
4
|
+
const WAITING_DAYS = 3
|
|
5
|
+
|
|
6
|
+
/** Whether a record that was closed at `closedAt` (null: never) and began at `at` still waits on the user at `now`. */
|
|
7
|
+
export const isStillWaiting = (closedAt: number | null, at: number, now: number): boolean =>
|
|
8
|
+
closedAt === null && now - at <= WAITING_DAYS * DAY_MS
|
package/src/preview/command.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { tmpdir } from 'node:os'
|
|
3
2
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
3
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
4
|
+
import { pageDirectoryOf, systemLocation } from '../shared/private-location'
|
|
4
5
|
import { openInBrowser } from '../terminal/open'
|
|
5
6
|
import { runGh } from '../watch/gh'
|
|
6
7
|
import { embedExternalImages, fetchImage } from './embed'
|
|
@@ -48,6 +49,28 @@ export const inlineImage = (root: string, path: string): string | null => {
|
|
|
48
49
|
return `data:${type};base64,${readFileSync(file).toString('base64')}`
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Writes the page to `out` when the user named one, else to `preview-<name>.html` in the user's private page directory
|
|
54
|
+
* (verified by `ensurePrivateDirectory`, written through `writePrivateFile`), never to a fixed name in the shared
|
|
55
|
+
* temporary directory; returns the path written.
|
|
56
|
+
*/
|
|
57
|
+
export const writePreviewPage = (
|
|
58
|
+
file: string,
|
|
59
|
+
page: string,
|
|
60
|
+
out: string | null,
|
|
61
|
+
pageDirectory: string = pageDirectoryOf(systemLocation()),
|
|
62
|
+
): string => {
|
|
63
|
+
if (out !== null) {
|
|
64
|
+
const chosen = resolve(out)
|
|
65
|
+
writeFileSync(chosen, page)
|
|
66
|
+
return chosen
|
|
67
|
+
}
|
|
68
|
+
ensurePrivateDirectory(pageDirectory)
|
|
69
|
+
const path = join(pageDirectory, `preview-${basename(file, '.md')}.html`)
|
|
70
|
+
writePrivateFile(path, page)
|
|
71
|
+
return path
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
/**
|
|
52
75
|
* `jh preview <markdown-file> [--out <path>] [--open]`: renders the file through GitHub's markdown API and writes a
|
|
53
76
|
* page that shows it in GitHub and in npm style, light and dark, and at phone width. The package's own images come
|
|
@@ -70,8 +93,7 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
70
93
|
const { html: rewritten, failed } = await embedExternalImages(local, fetchImage)
|
|
71
94
|
if (failed > 0)
|
|
72
95
|
console.error(`${failed} external ${failed === 1 ? 'image' : 'images'} could not be embedded and stay linked`)
|
|
73
|
-
const out =
|
|
74
|
-
writeFileSync(out, previewPage({ title: basename(file), html: rewritten }))
|
|
96
|
+
const out = writePreviewPage(file, previewPage({ title: basename(file), html: rewritten }), options.out)
|
|
75
97
|
console.log(out)
|
|
76
98
|
const reason = options.open ? openInBrowser(out) : null
|
|
77
99
|
if (reason) console.error(`could not open the page: ${reason}`)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, type Stats } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
import { ensurePrivateDirectory, isOwnedBy } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
|
+
|
|
6
|
+
type Entry = Pick<Stats, 'uid' | 'mode' | 'isDirectory' | 'isSymbolicLink'>
|
|
7
|
+
|
|
8
|
+
/** What choosing a private location reads from the system; the default is the real one (`systemLocation`). */
|
|
9
|
+
export type LocationSystem = {
|
|
10
|
+
env: Record<string, string | undefined>
|
|
11
|
+
temporaryDirectory: string
|
|
12
|
+
uid: number | undefined
|
|
13
|
+
/** What sits at the path, without following a link; null for nothing. */
|
|
14
|
+
entryAt: (path: string) => Entry | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PAGE_DIRECTORY_PREFIX = 'jackherd'
|
|
18
|
+
const GROUP_OR_OTHER = 0o077
|
|
19
|
+
|
|
20
|
+
const isPrivateDirectoryEntry = (entry: Entry | null, uid: number | undefined): boolean =>
|
|
21
|
+
entry !== null &&
|
|
22
|
+
!entry.isSymbolicLink() &&
|
|
23
|
+
entry.isDirectory() &&
|
|
24
|
+
isOwnedBy(entry, uid) &&
|
|
25
|
+
(entry.mode & GROUP_OR_OTHER) === 0
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where files only the user may read are kept: `$XDG_RUNTIME_DIR` when it is an absolute path to a directory of the
|
|
29
|
+
* user, no link, that neither group nor others may enter; else the temporary directory, which may be the shared `/tmp`
|
|
30
|
+
* and so needs a private level of its own below it (`userDirectoryOf`, `ensurePrivateLevels`).
|
|
31
|
+
*/
|
|
32
|
+
export const runtimeBaseOf = ({ env, temporaryDirectory, uid, entryAt }: LocationSystem): string => {
|
|
33
|
+
const runtime = env.XDG_RUNTIME_DIR
|
|
34
|
+
return runtime && isAbsolute(runtime) && isPrivateDirectoryEntry(entryAt(runtime), uid) ? runtime : temporaryDirectory
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The user's own level below `base`: `<base>/<prefix>-<uid>`, with `user` where the platform has no user ids. */
|
|
38
|
+
export const userDirectoryOf = (base: string, prefix: string, uid: number | undefined): string =>
|
|
39
|
+
join(base, `${prefix}-${uid ?? 'user'}`)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The user's directory for pages a browser opens: below the temporary directory, never the runtime directory, which a
|
|
43
|
+
* sandboxed browser may not reach.
|
|
44
|
+
*/
|
|
45
|
+
export const pageDirectoryOf = (system: LocationSystem): string =>
|
|
46
|
+
userDirectoryOf(system.temporaryDirectory, PAGE_DIRECTORY_PREFIX, system.uid)
|
|
47
|
+
|
|
48
|
+
const entryAt = (path: string): Entry | null => {
|
|
49
|
+
try {
|
|
50
|
+
return lstatSync(path)
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const systemLocation = (): LocationSystem => ({
|
|
57
|
+
env: process.env,
|
|
58
|
+
temporaryDirectory: tmpdir(),
|
|
59
|
+
uid: process.getuid?.(),
|
|
60
|
+
entryAt,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether `userDirectory` and `directory` (itself or a directory right below it) both exist as directories of the user,
|
|
65
|
+
* no link, closed to group and others; creates nothing, for readers that must not read from a level someone else
|
|
66
|
+
* planted.
|
|
67
|
+
*/
|
|
68
|
+
export const isPrivateLevels = (userDirectory: string, directory: string, system: LocationSystem): boolean =>
|
|
69
|
+
[userDirectory, directory].every((level) => isPrivateDirectoryEntry(system.entryAt(level), system.uid))
|
|
70
|
+
|
|
71
|
+
/** The text of a regular file; null for nothing, a link or anything else (a FIFO would block the read). */
|
|
72
|
+
export const readPrivateText = (path: string): string | null => {
|
|
73
|
+
try {
|
|
74
|
+
return lstatSync(path).isFile() ? readFileSync(path, 'utf8') : null
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Creates `directory`, the user level `userDirectory` itself or a directory right below it, and verifies each level
|
|
82
|
+
* with `ensurePrivateDirectory`, the user level first: nothing is created inside a level someone else owns or planted
|
|
83
|
+
* as a link, and this throws before anything is written there.
|
|
84
|
+
*/
|
|
85
|
+
export const ensurePrivateLevels = (userDirectory: string, directory: string): string => {
|
|
86
|
+
ensurePrivateDirectory(userDirectory)
|
|
87
|
+
if (directory !== userDirectory) ensurePrivateDirectory(directory)
|
|
88
|
+
return directory
|
|
89
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/** What tells a file changed without reading it: when it was last written and how large it is. */
|
|
4
|
+
export type FileStamp = { mtimeMs: number; size: number }
|
|
5
|
+
|
|
6
|
+
type Entry<T> = FileStamp & { value: T }
|
|
7
|
+
|
|
8
|
+
/** What was read from each file, by path, with the stamp it was read at. */
|
|
9
|
+
export type StatCache<T> = Map<string, Entry<T>>
|
|
10
|
+
|
|
11
|
+
/** A cache and the keys one round of reads asked for; `dropUnused` clears the rest at the end of the round. */
|
|
12
|
+
export type CacheRound<V> = { entries: Map<string, V>; used: Set<string> }
|
|
13
|
+
|
|
14
|
+
/** A round over a `StatCache`. */
|
|
15
|
+
export type StatRound<T> = CacheRound<Entry<T>>
|
|
16
|
+
|
|
17
|
+
const isUnchanged = (entry: FileStamp, stamp: FileStamp): boolean =>
|
|
18
|
+
entry.mtimeMs === stamp.mtimeMs && entry.size === stamp.size
|
|
19
|
+
|
|
20
|
+
/** Removes what no read of the round used, so a file that is gone does not stay in the cache. */
|
|
21
|
+
export const dropUnused = (cache: Map<string, unknown>, used: ReadonlySet<string>): void => {
|
|
22
|
+
for (const key of [...cache.keys()]) if (!used.has(key)) cache.delete(key)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stampOf = (path: string): FileStamp | null => {
|
|
26
|
+
try {
|
|
27
|
+
const { mtimeMs, size } = statSync(path)
|
|
28
|
+
return { mtimeMs, size }
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What `read` makes of the file, asked again only when its modification time or size changed since the cache saw it.
|
|
36
|
+
* A file that cannot be stat-ed is read every time and not kept. The `key` (the path unless the result also depends
|
|
37
|
+
* on something else, such as how much of the file is read) is noted in `used`, for `dropUnused`.
|
|
38
|
+
*/
|
|
39
|
+
export const readCached = <T>(round: StatRound<T>, path: string, read: (path: string) => T, key: string = path): T => {
|
|
40
|
+
round.used.add(key)
|
|
41
|
+
const stamp = stampOf(path)
|
|
42
|
+
if (!stamp) return read(path)
|
|
43
|
+
const kept = round.entries.get(key)
|
|
44
|
+
if (kept && isUnchanged(kept, stamp)) return kept.value
|
|
45
|
+
const value = read(path)
|
|
46
|
+
round.entries.set(key, { ...stamp, value })
|
|
47
|
+
return value
|
|
48
|
+
}
|
package/src/sync/kit-source.ts
CHANGED
|
@@ -9,10 +9,15 @@ import { parseKitPath } from './kit-path'
|
|
|
9
9
|
|
|
10
10
|
export type KitLocation = { kitDirectory: string; version: string; packageRoot: string }
|
|
11
11
|
|
|
12
|
-
/** What one kit module ships: its files and the
|
|
13
|
-
type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined }
|
|
12
|
+
/** What one kit module ships: its files and the texts of its `hooks.json` and `ask-rules.json`, if any. */
|
|
13
|
+
type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined; askRulesText: string | undefined }
|
|
14
14
|
|
|
15
15
|
const MODULE_HOOKS_FILE = 'hooks.json'
|
|
16
|
+
const MODULE_ASK_RULES_FILE = 'ask-rules.json'
|
|
17
|
+
/** Files a module ships to sync itself, never into `.claude/`. */
|
|
18
|
+
export const MODULE_DATA_FILES: readonly string[] = [MODULE_HOOKS_FILE, MODULE_ASK_RULES_FILE]
|
|
19
|
+
|
|
20
|
+
const askRulesSchema = z.array(z.string().min(1))
|
|
16
21
|
|
|
17
22
|
/** A test beside a kit script runs in this package and never ships into a project. */
|
|
18
23
|
const TEST_FILE = /\.test\.ts$/
|
|
@@ -68,6 +73,21 @@ const mergeModuleHooks = (modules: KitModule[]): string | undefined => {
|
|
|
68
73
|
return Object.keys(merged).length > 0 ? JSON.stringify(merged, null, 2) : undefined
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
/** The `permissions.ask` rules of every module's `ask-rules.json` (a JSON list of rules), each once, in module order. */
|
|
77
|
+
const mergeModuleAskRules = (modules: KitModule[]): string[] => {
|
|
78
|
+
const rules = modules.flatMap(({ name, askRulesText }) => {
|
|
79
|
+
if (askRulesText === undefined) return []
|
|
80
|
+
const invalid = new Error(`Kit ${name}/${MODULE_ASK_RULES_FILE} is not a JSON list of permission rules`)
|
|
81
|
+
const parsed = askRulesSchema.safeParse(parseJsonOr(askRulesText, invalid))
|
|
82
|
+
if (!parsed.success) throw invalid
|
|
83
|
+
return parsed.data
|
|
84
|
+
})
|
|
85
|
+
return [...new Set(rules)]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const readOptional = async (moduleDirectory: string, relatives: string[], file: string): Promise<string | undefined> =>
|
|
89
|
+
relatives.includes(file) ? readFile(join(moduleDirectory, file), 'utf8') : undefined
|
|
90
|
+
|
|
71
91
|
const listFiles = async (directory: string, prefix = ''): Promise<string[]> => {
|
|
72
92
|
const entries = await readdir(join(directory, prefix), { withFileTypes: true })
|
|
73
93
|
const nested = await Promise.all(
|
|
@@ -94,12 +114,11 @@ const readKitFile = async (moduleDirectory: string, relative: string): Promise<K
|
|
|
94
114
|
const readModule = async (kitDirectory: string, name: string): Promise<KitModule> => {
|
|
95
115
|
const moduleDirectory = join(kitDirectory, name)
|
|
96
116
|
const relatives = (await listFiles(moduleDirectory)).sort()
|
|
97
|
-
const filePaths = relatives.filter((relative) => relative
|
|
117
|
+
const filePaths = relatives.filter((relative) => !MODULE_DATA_FILES.includes(relative) && !TEST_FILE.test(relative))
|
|
98
118
|
const files = await Promise.all(filePaths.map((relative) => readKitFile(moduleDirectory, relative)))
|
|
99
|
-
const hooksText = relatives
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return { name, files, hooksText }
|
|
119
|
+
const hooksText = await readOptional(moduleDirectory, relatives, MODULE_HOOKS_FILE)
|
|
120
|
+
const askRulesText = await readOptional(moduleDirectory, relatives, MODULE_ASK_RULES_FILE)
|
|
121
|
+
return { name, files, hooksText, askRulesText }
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
|
|
@@ -119,8 +138,8 @@ const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
|
|
|
119
138
|
/**
|
|
120
139
|
* Reads the kit shipped with the package. Every directory under `kit/` is a module that mirrors
|
|
121
140
|
* `.claude/` (`kit/workflow/skills/plan/SKILL.md` → `.claude/skills/plan/SKILL.md`); a module's
|
|
122
|
-
* `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and
|
|
123
|
-
* copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
|
|
141
|
+
* `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and its
|
|
142
|
+
* `ask-rules.json` the `permissions.ask` rules a project install adds; neither is copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
|
|
124
143
|
*
|
|
125
144
|
* A missing kit directory, or one without modules, is an error, never an empty kit: an empty kit
|
|
126
145
|
* would make sync remove every untouched kit file from the project.
|
|
@@ -134,7 +153,7 @@ export const readKitSource = async ({
|
|
|
134
153
|
const modules = await Promise.all(names.map((name) => readModule(kitDirectory, name)))
|
|
135
154
|
assertUniquePaths(modules)
|
|
136
155
|
const files = modules.flatMap((module) => module.files).sort((left, right) => (left.path < right.path ? -1 : 1))
|
|
137
|
-
return { version, files, hooksTemplate: mergeModuleHooks(modules) }
|
|
156
|
+
return { version, files, hooksTemplate: mergeModuleHooks(modules), askRules: mergeModuleAskRules(modules) }
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
const readPackageJson = async (path: string) => packageJsonSchema.parse(JSON.parse(await readFile(path, 'utf8')))
|
package/src/sync/kit.ts
CHANGED
|
@@ -6,4 +6,6 @@ export type KitSource = {
|
|
|
6
6
|
files: KitFile[]
|
|
7
7
|
/** The unrendered text of `kit/hooks.json`, if the kit ships hooks. */
|
|
8
8
|
hooksTemplate: string | undefined
|
|
9
|
+
/** The `permissions.ask` rules of the kit's `ask-rules.json` files; none for a global install. */
|
|
10
|
+
askRules?: readonly string[]
|
|
9
11
|
}
|
package/src/sync/manifest.ts
CHANGED
|
@@ -21,6 +21,8 @@ const manifestSchema = z.strictObject({
|
|
|
21
21
|
z.string().regex(HOOK_NAME),
|
|
22
22
|
z.strictObject({ base: hookDefinitionSchema, pending: sha256.optional() }),
|
|
23
23
|
),
|
|
24
|
+
/** The `permissions.ask` rules sync put into `settings.json`, which it removes once the kit drops them. */
|
|
25
|
+
askRules: z.array(z.string().min(1)).optional(),
|
|
24
26
|
/** Checksum of the `settings.json.kit-new` sync wrote last. */
|
|
25
27
|
settingsKitNew: sha256.optional(),
|
|
26
28
|
})
|