jackherd 1.24.0 → 1.26.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 +9 -0
- package/INSTALL.md +1 -1
- package/README.md +22 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -1
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
- package/kit/hooks/hooks/open-question/record.ts +81 -1
- package/kit/hooks/hooks/open-question/run.ts +17 -3
- package/kit/hooks/hooks/question-session-end/index.ts +18 -0
- package/kit/hooks/hooks/question-session-end/run.ts +37 -0
- package/kit/hooks/hooks/sent-file/record.ts +4 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/hooks/hooks.json +8 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +7 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +9 -4
- package/src/config/schema.ts +3 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/closable-records.ts +11 -10
- package/src/plans/close-reason.ts +19 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +3 -2
- package/src/plans/question-records.ts +23 -2
- package/src/plans/questions-read.ts +2 -2
- package/src/plans/sent-files-read.ts +2 -2
- package/src/plans/sent-files.ts +12 -4
- package/src/plans/settings.ts +3 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/waiting/command.ts +82 -26
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +100 -6
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/lane-report.ts +4 -0
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/obsolete-proposals.ts +175 -0
- package/src/watch/obsolete-records.ts +77 -0
- package/src/watch/plan-missing-read.ts +3 -1
- package/src/watch/reference-paths.ts +2 -0
- package/src/watch/watch-events.ts +20 -0
|
@@ -13,8 +13,20 @@ export type QuestionRecord = {
|
|
|
13
13
|
cwd: string
|
|
14
14
|
session: string
|
|
15
15
|
closedAt: number | null
|
|
16
|
+
/** Who closed the record and why; absent or null on a record that predates the field or is open. */
|
|
17
|
+
closedReason?: string | null
|
|
18
|
+
/** A later dialog's question that probably answered this one in other words; null or absent when none did. */
|
|
19
|
+
supersededBy?: SupersededBy | null
|
|
20
|
+
/** When the session that asked this question ended with it still open (the `SessionEnd` hook); null or absent while it runs. */
|
|
21
|
+
sessionEndedAt?: number | null
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/** A later dialog's question that answered a record's question, and when. */
|
|
25
|
+
export type SupersededBy = AnsweredQuestion
|
|
26
|
+
|
|
27
|
+
const isSupersededBy = (value: unknown): value is SupersededBy =>
|
|
28
|
+
isRecord(value) && typeof value.question === 'string' && typeof value.answeredAt === 'number'
|
|
29
|
+
|
|
18
30
|
export type QuestionContext = {
|
|
19
31
|
cwd: string
|
|
20
32
|
session: string
|
|
@@ -76,6 +88,26 @@ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | nul
|
|
|
76
88
|
return results
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
/** How the client words the result of a dialog that a `PreToolUse` hook sent back: the user never saw it. */
|
|
92
|
+
const HOOK_REFUSAL = new RegExp(`^PreToolUse:${ASK_USER_QUESTION_TOOL} hook error`)
|
|
93
|
+
|
|
94
|
+
const resultTextOf = (block: JsonObject): string => {
|
|
95
|
+
if (typeof block.content === 'string') return block.content
|
|
96
|
+
if (!Array.isArray(block.content)) return ''
|
|
97
|
+
return block.content.map((part) => (isRecord(part) && typeof part.text === 'string' ? part.text : '')).join('\n')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The ids of the dialog calls a hook refused: the user was never asked, so they leave nothing open. */
|
|
101
|
+
const refusedByHookOf = (entries: readonly JsonObject[]): Set<string> =>
|
|
102
|
+
new Set(
|
|
103
|
+
entries.flatMap((entry) =>
|
|
104
|
+
contentOf(entry)
|
|
105
|
+
.filter((block) => block.type === CONTENT_BLOCK_TYPE.toolResult && block.is_error === true)
|
|
106
|
+
.filter((block) => HOOK_REFUSAL.test(resultTextOf(block)))
|
|
107
|
+
.flatMap((block) => (typeof block.tool_use_id === 'string' ? [block.tool_use_id] : [])),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
79
111
|
/** Whether the transcript went on after the call, other than with the rest of the message that made it. */
|
|
80
112
|
const wentOnAfter = (ask: Ask, entries: readonly JsonObject[]): boolean =>
|
|
81
113
|
entries
|
|
@@ -102,6 +134,49 @@ export const answeredSince = (
|
|
|
102
134
|
at: number,
|
|
103
135
|
): AnsweredQuestion | undefined => answered.find((done) => done.question === question && done.answeredAt > at)
|
|
104
136
|
|
|
137
|
+
/** The reason a record carries when a later dialog answered its question; the package's `closeReasonOf` spells it the same (contract test). */
|
|
138
|
+
export const ANSWERED_REASON = 'user: answered in a dialog'
|
|
139
|
+
|
|
140
|
+
/** Fewest words the shorter of two questions has to carry before one can be taken for a rewording of the other. */
|
|
141
|
+
const MIN_REWORDED_WORDS = 3
|
|
142
|
+
/** Share of the longer question's words the shorter one has to keep, with no word of its own besides. */
|
|
143
|
+
const MIN_REWORDED_SHARE = 0.7
|
|
144
|
+
|
|
145
|
+
/** Most words the longer question may add: one more word can be a restriction (`lane A`), two or more are another question. */
|
|
146
|
+
const MAX_EXTRA_WORDS = 1
|
|
147
|
+
/** A word that turns a question around: adding one is no rewording (`run it` against `do not run it`). */
|
|
148
|
+
const NEGATIONS: ReadonlySet<string> = new Set(['nicht', 'kein', 'keine', 'keinen', 'nie', 'not', 'no', 'never'])
|
|
149
|
+
|
|
150
|
+
const wordsOf = (question: string): Set<string> =>
|
|
151
|
+
new Set(question.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== ''))
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether two different questions read as one asked twice: all the words of the shorter one are in the longer one,
|
|
155
|
+
* and they are most of it. A word that only one side has and the other lacks (`A` against `B`, `export` against
|
|
156
|
+
* `import`) makes them two questions.
|
|
157
|
+
*/
|
|
158
|
+
export const isRewording = (left: string, right: string): boolean => {
|
|
159
|
+
const [short, long] = [wordsOf(left), wordsOf(right)].sort((first, second) => first.size - second.size)
|
|
160
|
+
if (short.size < MIN_REWORDED_WORDS || short.size / long.size < MIN_REWORDED_SHARE) return false
|
|
161
|
+
if (![...short].every((word) => long.has(word))) return false
|
|
162
|
+
const extra = [...long].filter((word) => !short.has(word))
|
|
163
|
+
return extra.length <= MAX_EXTRA_WORDS && !extra.some((word) => NEGATIONS.has(word))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The record with `supersededBy` set to the first dialog after it that answered a different question that reads as the
|
|
168
|
+
* same one reworded; the record itself when none did or one is set already. Only a suspicion for the coordinator to
|
|
169
|
+
* weigh: it never closes the record.
|
|
170
|
+
*/
|
|
171
|
+
export const withSupersededBy = (record: QuestionRecord, answered: readonly AnsweredQuestion[]): QuestionRecord => {
|
|
172
|
+
if (record.closedAt !== null || record.supersededBy) return record
|
|
173
|
+
const later = answered.find(
|
|
174
|
+
({ question, answeredAt }) =>
|
|
175
|
+
answeredAt > record.askedAt && question !== record.question && isRewording(question, record.question),
|
|
176
|
+
)
|
|
177
|
+
return later ? { ...record, supersededBy: { question: later.question, answeredAt: later.answeredAt } } : record
|
|
178
|
+
}
|
|
179
|
+
|
|
105
180
|
/**
|
|
106
181
|
* The questions of the main thread that stand open: their dialog was rejected or interrupted, or answered with "later",
|
|
107
182
|
* and no dialog answered the same text since. A call that carries its own answers asked nobody; a sidechain is no dialog
|
|
@@ -110,7 +185,9 @@ export const answeredSince = (
|
|
|
110
185
|
export const openQuestionsOf = (entries: readonly unknown[], context: QuestionContext): QuestionRecord[] => {
|
|
111
186
|
const thread = mainThread(entries)
|
|
112
187
|
const results = resultsOf(thread)
|
|
188
|
+
const refused = refusedByHookOf(thread)
|
|
113
189
|
return asksOf(thread)
|
|
190
|
+
.filter((ask) => !refused.has(ask.toolUseId))
|
|
114
191
|
.filter((ask) => endedUnanswered(ask, results, thread))
|
|
115
192
|
.map((ask) => ({ ask, question: questionTextOf(ask.question) }))
|
|
116
193
|
.filter(({ ask, question }) => question !== '' && !answeredSince(context.answered, question, ask.askedAt))
|
|
@@ -133,4 +210,7 @@ export const isQuestionRecord = (value: unknown): value is QuestionRecord =>
|
|
|
133
210
|
typeof value.askedAt === 'number' &&
|
|
134
211
|
typeof value.cwd === 'string' &&
|
|
135
212
|
typeof value.session === 'string' &&
|
|
136
|
-
(value.closedAt === null || typeof value.closedAt === 'number')
|
|
213
|
+
(value.closedAt === null || typeof value.closedAt === 'number') &&
|
|
214
|
+
(value.closedReason == null || typeof value.closedReason === 'string') &&
|
|
215
|
+
(value.supersededBy == null || isSupersededBy(value.supersededBy)) &&
|
|
216
|
+
(value.sessionEndedAt == null || typeof value.sessionEndedAt === 'number')
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ASK_USER_QUESTION_TOOL, parseTranscript } from '../shared/dialog-answers'
|
|
2
2
|
import { isRecord } from '../shared/json'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ANSWERED_REASON,
|
|
5
|
+
answeredQuestionsOf,
|
|
6
|
+
answeredSince,
|
|
7
|
+
openQuestionsOf,
|
|
8
|
+
type QuestionRecord,
|
|
9
|
+
withSupersededBy,
|
|
10
|
+
} from './record'
|
|
4
11
|
|
|
5
12
|
/** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
|
|
6
13
|
export type OpenQuestionDependencies = {
|
|
@@ -42,14 +49,21 @@ export const runOpenQuestion = (input: unknown, dependencies: OpenQuestionDepend
|
|
|
42
49
|
const answered = answeredQuestionsOf(entries)
|
|
43
50
|
for (const record of stored.filter(({ closedAt }) => closedAt === null)) {
|
|
44
51
|
const answer = answeredSince(answered, record.question, record.askedAt)
|
|
45
|
-
if (answer)
|
|
52
|
+
if (answer) {
|
|
53
|
+
dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt, closedReason: ANSWERED_REASON })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (record.session !== session) continue
|
|
57
|
+
const marked = withSupersededBy(record, answered)
|
|
58
|
+
if (marked !== record) dependencies.writeRecord(main, marked)
|
|
59
|
+
else if (record.sessionEndedAt != null) dependencies.writeRecord(main, { ...record, sessionEndedAt: null })
|
|
46
60
|
}
|
|
47
61
|
const known = new Set(stored.map(({ id }) => id))
|
|
48
62
|
const openTexts = new Set(stored.filter(({ closedAt }) => closedAt === null).map(({ question }) => question))
|
|
49
63
|
for (const record of openQuestionsOf(entries, { cwd, session, answered, idOf: dependencies.idOf })) {
|
|
50
64
|
if (known.has(record.id) || openTexts.has(record.question)) continue
|
|
51
65
|
openTexts.add(record.question)
|
|
52
|
-
dependencies.writeRecord(main, record)
|
|
66
|
+
dependencies.writeRecord(main, withSupersededBy(record, answered))
|
|
53
67
|
}
|
|
54
68
|
} catch {
|
|
55
69
|
// A failed record is no record; the herd shows no question.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SessionEnd hook of Claude Code, shipped by jackherd: marks the still open question records that the ending session
|
|
2
|
+
// asked (`sessionEndedAt` in `.jackherd/questions/` of the main checkout), also on `/clear`, so the watcher can propose
|
|
3
|
+
// them as `session ended`. It marks only its own session's records, prints nothing, never blocks and always exits 0.
|
|
4
|
+
import { mainCheckoutOf } from '../shared/checkout'
|
|
5
|
+
import { readQuestionRecords, writeQuestionRecord } from '../open-question/store'
|
|
6
|
+
import { runQuestionSessionEnd } from './run'
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
runQuestionSessionEnd(JSON.parse(await Bun.stdin.text()), {
|
|
10
|
+
mainCheckoutOf,
|
|
11
|
+
readRecords: readQuestionRecords,
|
|
12
|
+
writeRecord: writeQuestionRecord,
|
|
13
|
+
now: Date.now,
|
|
14
|
+
})
|
|
15
|
+
} catch {
|
|
16
|
+
// Unreadable input marks nothing.
|
|
17
|
+
}
|
|
18
|
+
process.exit(0)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isRecord } from '../shared/json'
|
|
2
|
+
import type { QuestionRecord } from '../open-question/record'
|
|
3
|
+
|
|
4
|
+
/** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
|
|
5
|
+
export type SessionEndDependencies = {
|
|
6
|
+
mainCheckoutOf: (cwd: string) => string
|
|
7
|
+
readRecords: (main: string) => QuestionRecord[]
|
|
8
|
+
writeRecord: (main: string, record: QuestionRecord) => void
|
|
9
|
+
now: () => number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The open question records that `session` asked and that carry no end mark yet, marked `sessionEndedAt`. Only the
|
|
14
|
+
* records of the ending session: another session in the same directory is not touched.
|
|
15
|
+
*/
|
|
16
|
+
export const endedRecords = (records: readonly QuestionRecord[], session: string, now: number): QuestionRecord[] =>
|
|
17
|
+
records
|
|
18
|
+
.filter((record) => record.session === session && record.closedAt === null && record.sessionEndedAt == null)
|
|
19
|
+
.map((record) => ({ ...record, sessionEndedAt: now }))
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Marks the ending session's still open questions. Claude Code's `SessionEnd` hook input: `session_id`, `cwd`, `reason`
|
|
23
|
+
* (`clear`, `logout`, `prompt_input_exit`, `resume`, `other`); every reason ends the session's own questions, the mark
|
|
24
|
+
* is only a suspicion for the watcher to propose. Writes nothing on any failure.
|
|
25
|
+
*/
|
|
26
|
+
export const runQuestionSessionEnd = (input: unknown, dependencies: SessionEndDependencies): void => {
|
|
27
|
+
try {
|
|
28
|
+
if (!isRecord(input)) return
|
|
29
|
+
const { cwd, session_id: session } = input
|
|
30
|
+
if (typeof cwd !== 'string' || typeof session !== 'string') return
|
|
31
|
+
const main = dependencies.mainCheckoutOf(cwd)
|
|
32
|
+
for (const record of endedRecords(dependencies.readRecords(main), session, dependencies.now()))
|
|
33
|
+
dependencies.writeRecord(main, record)
|
|
34
|
+
} catch {
|
|
35
|
+
// A failed mark is no mark; the question stays open as before.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -15,6 +15,8 @@ export type SentRecord = {
|
|
|
15
15
|
session: string
|
|
16
16
|
agentId: string | null
|
|
17
17
|
closedAt: number | null
|
|
18
|
+
/** Who closed the record and why; absent or null on a record that predates the field or is open. */
|
|
19
|
+
closedReason?: string | null
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export type SentCall = {
|
|
@@ -125,4 +127,5 @@ export const isSentRecord = (value: unknown): value is SentRecord =>
|
|
|
125
127
|
typeof value.cwd === 'string' &&
|
|
126
128
|
typeof value.session === 'string' &&
|
|
127
129
|
(value.agentId === null || typeof value.agentId === 'string') &&
|
|
128
|
-
(value.closedAt === null || typeof value.closedAt === 'number')
|
|
130
|
+
(value.closedAt === null || typeof value.closedAt === 'number') &&
|
|
131
|
+
(value.closedReason == null || typeof value.closedReason === 'string')
|
|
@@ -27,16 +27,38 @@ const PLAN_COLUMN = 2
|
|
|
27
27
|
|
|
28
28
|
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
29
29
|
|
|
30
|
-
const
|
|
30
|
+
const MARKER = /<!--\s*jh:\s*([a-z-]+)\s*-->/
|
|
31
|
+
|
|
32
|
+
const markerOf = (text: string | undefined): string | undefined => (text === undefined ? undefined : MARKER.exec(text)?.[1])
|
|
33
|
+
|
|
34
|
+
const languagesFrom = (language: BoardLanguage): BoardLanguage[] =>
|
|
35
|
+
[language, ...(Object.keys(BOARD_WORDS) as BoardLanguage[]).filter((other) => other !== language)]
|
|
36
|
+
|
|
37
|
+
/** The index of the lanes section's heading: the marker `<!-- jh: lanes -->` under it, else the heading of `language`, else of the other language. */
|
|
38
|
+
const lanesHeadingIndex = (lines: string[], language: BoardLanguage): number => {
|
|
39
|
+
const marked = lines.findIndex((line, index) => SECTION_END.test(line) && markerOf(lines[index + 1]) === 'lanes')
|
|
40
|
+
if (marked !== -1) return marked
|
|
41
|
+
for (const candidate of languagesFrom(language)) {
|
|
42
|
+
const pattern = new RegExp(`^##\\s+${escapeRegExp(BOARD_WORDS[candidate].lanes)}\\s*$`)
|
|
43
|
+
const found = lines.findIndex((line) => pattern.test(line))
|
|
44
|
+
if (found !== -1) return found
|
|
45
|
+
}
|
|
46
|
+
return -1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lanesSectionLines = (markdown: string, language: BoardLanguage): string[] => {
|
|
31
50
|
const lines = markdown.split('\n')
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const rest = lines.slice(start + 1)
|
|
51
|
+
const heading = lanesHeadingIndex(lines, language)
|
|
52
|
+
if (heading === -1) return []
|
|
53
|
+
const rest = lines.slice(heading + 1)
|
|
36
54
|
const end = rest.findIndex((line) => SECTION_END.test(line))
|
|
37
55
|
return end === -1 ? rest : rest.slice(0, end)
|
|
38
56
|
}
|
|
39
57
|
|
|
58
|
+
/** Whether a lane's name cell says the lane was cleaned up: the marker `<!-- jh: lane-closed -->`, or the word of any language. */
|
|
59
|
+
const isClosedCell = (cell: string): boolean =>
|
|
60
|
+
markerOf(cell) === 'lane-closed' || Object.values(BOARD_WORDS).some(({ laneClosed }) => cell.includes(laneClosed))
|
|
61
|
+
|
|
40
62
|
const cellsOf = (line: string): string[] =>
|
|
41
63
|
line
|
|
42
64
|
.slice(1, line.endsWith('|') ? -1 : undefined)
|
|
@@ -55,7 +77,7 @@ const firstTableLines = (lines: string[]): string[] => {
|
|
|
55
77
|
const laneRows = (markdown: string, language: BoardLanguage): Row[] => {
|
|
56
78
|
const rows: Row[] = []
|
|
57
79
|
let previous: string | null = null
|
|
58
|
-
for (const line of firstTableLines(
|
|
80
|
+
for (const line of firstTableLines(lanesSectionLines(markdown, language))) {
|
|
59
81
|
const cells = cellsOf(line)
|
|
60
82
|
const first = cells[0] ?? ''
|
|
61
83
|
const lane: string | null = first.match(LANE_NAME)?.[1] ?? (first.startsWith(CONTINUATION) ? previous : null)
|
|
@@ -73,11 +95,11 @@ const planPathOf = (boardDirectory: string, cell: string): string[] => {
|
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
/** The rows of one lane since it was last cleaned up: a named row without the mark after a closed one starts it anew. */
|
|
76
|
-
const currentRows = <Row extends { cells: string[] }>(rows: Row[]
|
|
98
|
+
const currentRows = <Row extends { cells: string[] }>(rows: Row[]): Row[] =>
|
|
77
99
|
rows.reduce<{ closed: boolean; kept: Row[] }>(
|
|
78
100
|
({ closed, kept }, row) => {
|
|
79
101
|
const first = row.cells[0] ?? ''
|
|
80
|
-
const marked = first
|
|
102
|
+
const marked = isClosedCell(first)
|
|
81
103
|
const startsAnew = closed && !marked && !first.startsWith(CONTINUATION)
|
|
82
104
|
return { closed: marked || (closed && !startsAnew), kept: startsAnew ? [row] : [...kept, row] }
|
|
83
105
|
},
|
|
@@ -100,11 +122,10 @@ export const boardPlansOf = (markdown: string, boardDirectory: string, language:
|
|
|
100
122
|
|
|
101
123
|
/** Every lane the lanes table names whose current rows do not say it was cleaned up, in table order, read in one pass. */
|
|
102
124
|
export const openLaneNamesOf = (markdown: string, language: BoardLanguage): string[] => {
|
|
103
|
-
const { laneClosed } = BOARD_WORDS[language]
|
|
104
125
|
const rowsByLane = new Map<string, Row[]>()
|
|
105
126
|
for (const row of laneRows(markdown, language)) rowsByLane.set(row.lane, [...(rowsByLane.get(row.lane) ?? []), row])
|
|
106
127
|
return [...rowsByLane].flatMap(([lane, rows]) =>
|
|
107
|
-
currentRows(rows
|
|
128
|
+
currentRows(rows).some(({ cells }) => isClosedCell(cells[0] ?? '')) ? [] : [lane],
|
|
108
129
|
)
|
|
109
130
|
}
|
|
110
131
|
|
|
@@ -117,9 +138,9 @@ export const lanePlansOf = (
|
|
|
117
138
|
): LanePlans | undefined => {
|
|
118
139
|
const all = laneRows(markdown, language).filter((row) => row.lane === lane)
|
|
119
140
|
if (all.length === 0) return undefined
|
|
120
|
-
const rows = currentRows(all
|
|
141
|
+
const rows = currentRows(all)
|
|
121
142
|
return {
|
|
122
|
-
closed: rows.some(({ cells }) => (cells[0] ?? '')
|
|
143
|
+
closed: rows.some(({ cells }) => isClosedCell(cells[0] ?? '')),
|
|
123
144
|
plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
|
|
124
145
|
}
|
|
125
146
|
}
|
|
@@ -12,9 +12,13 @@ import { isRepositoryDirectory } from './repository-directory'
|
|
|
12
12
|
/** A board's directory relative to the repository, and its text. */
|
|
13
13
|
export type Board = { directory: string; text: string }
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The plan language, the language the board is written in (the plan language when the config sets none), the plan
|
|
17
|
+
* directory and the board pattern and file below it, and where the lane worktrees live.
|
|
18
|
+
*/
|
|
16
19
|
export type BoardLocation = {
|
|
17
20
|
planLanguage: BoardLanguage
|
|
21
|
+
boardLanguage: BoardLanguage
|
|
18
22
|
planDirectory: string
|
|
19
23
|
board: { pattern: string; file: string }
|
|
20
24
|
worktreeDirectory: string
|
|
@@ -42,8 +46,16 @@ const isPlanLanguage = (value: unknown): value is BoardLanguage => typeof value
|
|
|
42
46
|
* The board location's values of a config, each with its default where absent and `undefined` where present but
|
|
43
47
|
* invalid; `board` is the config's `board` section.
|
|
44
48
|
*/
|
|
45
|
-
export const boardLocationValues = (config: Record<string, unknown>, board: Record<string, unknown>) =>
|
|
46
|
-
planLanguage
|
|
49
|
+
export const boardLocationValues = (config: Record<string, unknown>, board: Record<string, unknown>) => {
|
|
50
|
+
const planLanguage = valueOr(config, 'planLanguage', isPlanLanguage, 'en')
|
|
51
|
+
return {
|
|
52
|
+
planLanguage,
|
|
53
|
+
boardLanguage: valueOr(config, 'boardLanguage', isPlanLanguage, planLanguage ?? 'en'),
|
|
54
|
+
...directoryValues(config, board),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const directoryValues = (config: Record<string, unknown>, board: Record<string, unknown>) => ({
|
|
47
59
|
planDirectory: valueOr(config, 'planDirectory', isRepositoryDirectory, 'docs/plans'),
|
|
48
60
|
pattern: valueOr(board, 'pattern', matches(BOARD_PATTERN), 'board-*'),
|
|
49
61
|
file: valueOr(board, 'file', matches(BOARD_FILE), 'board.md'),
|
|
@@ -54,10 +66,14 @@ export const boardLocationValues = (config: Record<string, unknown>, board: Reco
|
|
|
54
66
|
export const boardLocationOf = (config: Record<string, unknown>): BoardLocation | null => {
|
|
55
67
|
const board = config.board ?? {}
|
|
56
68
|
if (!isRecord(board)) return null
|
|
57
|
-
const { planLanguage, planDirectory, pattern, file, worktreeDirectory } = boardLocationValues(
|
|
58
|
-
|
|
69
|
+
const { planLanguage, boardLanguage, planDirectory, pattern, file, worktreeDirectory } = boardLocationValues(
|
|
70
|
+
config,
|
|
71
|
+
board,
|
|
72
|
+
)
|
|
73
|
+
if (planLanguage === undefined || boardLanguage === undefined) return null
|
|
74
|
+
if (planDirectory === undefined || worktreeDirectory === undefined) return null
|
|
59
75
|
if (pattern === undefined || file === undefined) return null
|
|
60
|
-
return { planLanguage, planDirectory, board: { pattern, file }, worktreeDirectory }
|
|
76
|
+
return { planLanguage, boardLanguage, planDirectory, board: { pattern, file }, worktreeDirectory }
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
/** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -96,6 +96,14 @@
|
|
|
96
96
|
"timeout": 10
|
|
97
97
|
}
|
|
98
98
|
},
|
|
99
|
+
"question-session-end": {
|
|
100
|
+
"event": "SessionEnd",
|
|
101
|
+
"handler": {
|
|
102
|
+
"type": "command",
|
|
103
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/question-session-end/index.ts",
|
|
104
|
+
"timeout": 5
|
|
105
|
+
}
|
|
106
|
+
},
|
|
99
107
|
"return-brief": {
|
|
100
108
|
"event": "UserPromptSubmit",
|
|
101
109
|
"handler": {
|
|
@@ -8,13 +8,13 @@ is missing or comes twice.
|
|
|
8
8
|
It reads the plans at `origin/{{kit.baseBranch}}`. From a lane's worktree, once the lane has work
|
|
9
9
|
there, it takes the phases the lane's rows name and any phase that is new there or ticked further
|
|
10
10
|
than on the base. It redraws when a plan file changes or a fetch of the base branch lands in the
|
|
11
|
-
main checkout (the watcher fetches it as soon as the remote moved). While it runs
|
|
12
|
-
lane's sidebar title (`P4 · 7/12`)
|
|
11
|
+
main checkout (the watcher fetches it as soon as the remote moved). While it runs it sets each
|
|
12
|
+
lane's sidebar title (`P4 · 7/12`), clearing it on exit. An opened lane adds each phase's purpose line.
|
|
13
13
|
|
|
14
14
|
A phase the lane ticked to the end in its worktree stays its current phase until the base branch
|
|
15
15
|
carries its ticks (the watcher marks it `abgehakt, nicht gemergt` / `ticked, not merged`; the herd
|
|
16
16
|
shows the branch's `↑n` instead); an open phase the lane works on goes first. The lane's newest row decides
|
|
17
|
-
which phases count: ticks in an older phase
|
|
17
|
+
which phases count: ticks in an older phase take over only when that row names no
|
|
18
18
|
phase or only merged ones, which is why each new row starts with its phase number (§ 2 step 5).
|
|
19
19
|
|
|
20
20
|
## Notices per lane
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# § 4 Waiting for you: the rows beyond the basic one
|
|
2
2
|
|
|
3
|
-
Read when `jh waiting` or the heartbeat's `for you (<n>)` names this file, a lane waits on the user's answer,
|
|
3
|
+
Read when a watcher line `waiting closed` or `waiting obsolete?`, `jh waiting` or the heartbeat's `for you (<n>)` names this file, a lane waits on the user's answer,
|
|
4
4
|
or something you sent the user needs no row of its own.
|
|
5
5
|
|
|
6
6
|
- **A lane waits on the answer:** add its name as in the lanes table (a phase may follow) to the optional
|
|
@@ -14,3 +14,17 @@ or something you sent the user needs no row of its own.
|
|
|
14
14
|
the user's. A dialog that ended without an answer (declined, cut off, or answered "later") needs none
|
|
15
15
|
either: the kit hook `open-question` records it, and it closes once the same question is answered in a
|
|
16
16
|
dialog.
|
|
17
|
+
- **The watcher closes a send by itself** when a newer send has the same first entry (`newer version: <id>`,
|
|
18
|
+
whoever sent it) or its lane's worktree stayed gone for two heartbeats (`lane removed: <name>`), and reports
|
|
19
|
+
`waiting closed <n>: <id> <reason>; …`. `jh waiting --closed` lists the last 24 hours with reasons.
|
|
20
|
+
- **A proposal `waiting obsolete? <key> <reason> "<text>"`** closes nothing and comes once per reason. Check
|
|
21
|
+
it against the thing itself. A key `row-…` is a board row (find it by the quoted text): set its status cell to
|
|
22
|
+
`done` (`erledigt` in a German board) with `(<you> <DD.MM.> <HH:MM>: <why>)`; the table is yours alone, no
|
|
23
|
+
code writes into it. Any other key is a record id: `jh waiting close <id> --reason "<why>"`. A thing still
|
|
24
|
+
wanted stays open. Reasons: `pr #<n> merged|closed`,
|
|
25
|
+
`lane removed: <name>`, `superseded: <question>` (a later dialog answered it in nearly the same words),
|
|
26
|
+
`session ended`.
|
|
27
|
+
- **A decision the hooks cannot see** (made in the chat or in a lane's report, written into the board by you):
|
|
28
|
+
close the records it settles in the same turn, `jh waiting close <id> --reason "decided: <decision>"`.
|
|
29
|
+
- **A board row's key is a hash of its item text:** a reworded row gets a new key and can raise its proposal
|
|
30
|
+
again, as can an unreadable `.jackherd/sent`; close it then.
|
|
@@ -11,6 +11,8 @@ You create a structured concept/plan document in `{{kit.planDirectory}}/`.
|
|
|
11
11
|
> written in {{kit.planLanguage}}. Keep the section headings of the template **verbatim** — the plan
|
|
12
12
|
> parser, `/implement` and `spec-review` find `## {{kit.heading.decisions}}`,
|
|
13
13
|
> `## {{kit.heading.implementation}}` and `## {{kit.heading.documentation}}` by those exact names.
|
|
14
|
+
> Another language: each heading, table head and purpose line carries `<!-- jh: <key> -->` (keys:
|
|
15
|
+
> `docs/plan-contract.md`, "Language and markers"); done when `jh plan check` shows no `missing`.
|
|
14
16
|
|
|
15
17
|
## Your Workflow
|
|
16
18
|
|
|
@@ -36,7 +38,7 @@ The closing documentation section reads `## N — …` with rows `N.1` to `N.5`:
|
|
|
36
38
|
|
|
37
39
|
A checkbox moved on purpose to a later phase stays where it is, unticked, and ends in the target: `- [ ] Update the documentation → P9`. The herd counts it neither as open nor as done in its own phase and lists it as open in phase 9. The arrow stands only at the end of the line. Once a phase's tasks are ticked on the base branch, every box still open in it, gate boxes included, is ticked or moved on this way; `jh check` checks both.
|
|
38
40
|
|
|
39
|
-
A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result (`Heartbeat shows progress without ticks`); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name;
|
|
41
|
+
A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result (`Heartbeat shows progress without ticks`); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name; directly under it stands the purpose line of the template, one sentence saying what is different afterwards and for whom, so that a reader outside the project understands the phase from that line alone; `jh check` asks for it on every new phase. The phase's context goes into its origin line, a quote below the purpose line (`> Origin: …`, in the plan's language). A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
|
|
40
42
|
|
|
41
43
|
An Artifact that belongs to the plan (a mockup, a draft for review, a findings page) stands as a Markdown link with its title in the phase it concerns, in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. The herd shows them as chips; nothing else ties an Artifact published from the main checkout to the lane.
|
|
42
44
|
|
|
@@ -50,6 +50,8 @@ What changes concretely? Files, modules, flows.
|
|
|
50
50
|
|
|
51
51
|
### {{kit.heading.phase}} 1: ...
|
|
52
52
|
|
|
53
|
+
**What it is about:** what is different afterwards, for whom; "nothing visible" when that is the case
|
|
54
|
+
|
|
53
55
|
- [ ] **Name of step 1**: how, conditions, origin, evidence
|
|
54
56
|
- [ ] **Name of step 2**: how, conditions, origin, evidence
|
|
55
57
|
|
|
@@ -50,6 +50,8 @@ Was ändert sich konkret? Dateien, Module, Flows.
|
|
|
50
50
|
|
|
51
51
|
### {{kit.heading.phase}} 1: ...
|
|
52
52
|
|
|
53
|
+
**Worum es geht:** was danach anders ist, für wen; „nichts Sichtbares“, wenn das so ist
|
|
54
|
+
|
|
53
55
|
- [ ] **Name von Schritt 1**: Wie, Bedingungen, Herkunft, Beleg
|
|
54
56
|
- [ ] **Name von Schritt 2**: Wie, Bedingungen, Herkunft, Beleg
|
|
55
57
|
|
package/package.json
CHANGED
package/src/brief/command.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
35
35
|
const here = worktreeRoot(process.cwd())
|
|
36
36
|
const settings = readSettings([here, root])
|
|
37
37
|
const picked = boardFor(boardPath, [root, here], settings, () => foundLanesOf(root, settings, () => []))
|
|
38
|
-
for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.
|
|
38
|
+
for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.boardLanguage))
|
|
39
39
|
console.log(line)
|
|
40
40
|
return 0
|
|
41
41
|
} catch (error) {
|
package/src/brief/lane-facts.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join, relative } from 'node:path'
|
|
|
3
3
|
import { parseSprintLanes, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import { type QuestionRecord } from '../plans/question-records'
|
|
5
5
|
import { type SentFileRecord, sentLaneOf } from '../plans/sent-files'
|
|
6
|
-
import type
|
|
6
|
+
import { type PlansSettings } from '../plans/settings'
|
|
7
7
|
import { laneDirectoryOf } from '../plans/unlisted-lanes'
|
|
8
8
|
import { readWaitingRecords } from '../plans/waiting-records'
|
|
9
9
|
import { isStillWaiting } from '../plans/waiting-window'
|
|
@@ -49,7 +49,7 @@ export const laneFactsOf = (root: string, settings: PlansSettings, boardFile: st
|
|
|
49
49
|
const sprintDirectory = dirname(relative(root, boardFile))
|
|
50
50
|
const worktreeDirectory = join(root, settings.worktreeDirectory)
|
|
51
51
|
const records = readWaitingRecords(root)
|
|
52
|
-
return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.
|
|
52
|
+
return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
|
|
53
53
|
.filter(({ closed }) => !closed)
|
|
54
54
|
.map((lane) => {
|
|
55
55
|
const href = planHrefOf(lane)
|
package/src/check/command.ts
CHANGED
|
@@ -223,7 +223,7 @@ const openBoxesCheckOf = (root: string, settings: CheckSettings): CheckResult |
|
|
|
223
223
|
lane,
|
|
224
224
|
planDirectory: settings.planDirectory,
|
|
225
225
|
board: settings.board,
|
|
226
|
-
language: settings.
|
|
226
|
+
language: settings.boardLanguage,
|
|
227
227
|
level: settings.check.openBoxes,
|
|
228
228
|
baseOf: contentAt(root, baseRefOf(settings)),
|
|
229
229
|
})
|
|
@@ -253,6 +253,7 @@ const ownChecksOf = (
|
|
|
253
253
|
paths,
|
|
254
254
|
planDirectory: settings.planDirectory,
|
|
255
255
|
language: settings.planLanguage,
|
|
256
|
+
boardLanguage: settings.boardLanguage,
|
|
256
257
|
previousOf: contentAt(root, 'HEAD'),
|
|
257
258
|
baseOf: contentAt(root, baseRefOf(settings)),
|
|
258
259
|
planRoots: planRootsOf(root, settings.worktreeDirectory),
|