jackherd 1.8.1 → 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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- 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/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -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 -140
- 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/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- 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 +21 -4
- package/kit/hooks/statusline.sh +1 -1
- 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 +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- 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 +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- 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 +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- 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 +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- 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/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- 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-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -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 +23 -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 +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- 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 +131 -0
- package/src/plans/subagents.ts +190 -0
- 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 +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -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/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- 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/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- 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/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- 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 +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- 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/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- 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,5 +1,5 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { PHASE_ID_PREFIX, phaseLabels } from './lanes'
|
|
2
|
+
import { PHASE_ID_PREFIX, phaseLabels, spannedPhaseIds } from './lanes'
|
|
3
3
|
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
5
|
import { isPhaseFinished } from './progress'
|
|
@@ -40,7 +40,6 @@ const VERSION = /^v?\d+(?:[.\-+][\w.-]*)?$/i
|
|
|
40
40
|
const PACKAGE_SCOPE = /^@[^/]+\//
|
|
41
41
|
/** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
|
|
42
42
|
const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
|
|
43
|
-
const MAX_RANGE = 50
|
|
44
43
|
|
|
45
44
|
const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
46
45
|
|
|
@@ -63,9 +62,7 @@ const splitOutsideParentheses = (cell: string, separator: string): string[] => {
|
|
|
63
62
|
const rangeOf = (entry: string): string[] | null => {
|
|
64
63
|
const match = PHASE_RANGE.exec(entry)
|
|
65
64
|
if (!match) return null
|
|
66
|
-
|
|
67
|
-
if (first > last || last - first >= MAX_RANGE) return []
|
|
68
|
-
return Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
|
|
65
|
+
return spannedPhaseIds(Number(match[1]), Number(match[2]))
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'after' | 'external'> => {
|
|
@@ -203,18 +200,21 @@ const longestChain = (chains: readonly string[][]): string[] =>
|
|
|
203
200
|
const mergeSets = (sets: readonly (string[] | null)[]): string[] | null =>
|
|
204
201
|
sets.some((set) => set === null) ? null : sets.flatMap((set) => set ?? [])
|
|
205
202
|
|
|
206
|
-
|
|
207
|
-
const writeSetOf = (
|
|
203
|
+
const writeSetWithin = (
|
|
208
204
|
row: PhaseDependency,
|
|
209
205
|
byId: ReadonlyMap<string, PhaseDependency>,
|
|
210
206
|
chain: readonly string[],
|
|
211
207
|
): string[] | null => {
|
|
212
208
|
if (chain.includes(row.id)) return null
|
|
213
209
|
const targets = row.writesLike.map((id) => byId.get(id))
|
|
214
|
-
const like = mergeSets(targets.map((target) => (target ?
|
|
210
|
+
const like = mergeSets(targets.map((target) => (target ? writeSetWithin(target, byId, [...chain, row.id]) : null)))
|
|
215
211
|
return like === null ? null : [...row.writes, ...like]
|
|
216
212
|
}
|
|
217
213
|
|
|
214
|
+
/** The paths a phase writes, those of every phase its `like` cell names included; null for an unknown target or a cycle. */
|
|
215
|
+
export const writeSetOf = (row: PhaseDependency, byId: ReadonlyMap<string, PhaseDependency>): string[] | null =>
|
|
216
|
+
writeSetWithin(row, byId, [])
|
|
217
|
+
|
|
218
218
|
const overlaps = (left: string, right: string): boolean =>
|
|
219
219
|
left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
220
220
|
|
|
@@ -231,7 +231,7 @@ const collides = (first: Candidate, second: Candidate): boolean =>
|
|
|
231
231
|
sharesMarker(first, second) || first.writes.some((path) => second.writes.some((other) => overlaps(path, other)))
|
|
232
232
|
|
|
233
233
|
const candidateOf = (row: PhaseDependency, byId: ReadonlyMap<string, PhaseDependency>): Candidate[] => {
|
|
234
|
-
const writes = writeSetOf(row, byId
|
|
234
|
+
const writes = writeSetOf(row, byId)
|
|
235
235
|
if (writes === null || writes.length === 0) return []
|
|
236
236
|
const releases = [...new Set([...row.releases, ...releasedByFiles(writes)])]
|
|
237
237
|
return [{ id: row.id, writes, serialMarkers: row.serialMarkers, releases }]
|
package/src/plan-format/index.ts
CHANGED
package/src/plan-format/lanes.ts
CHANGED
|
@@ -32,6 +32,15 @@ export const PHASE_ID_PREFIX = 'P'
|
|
|
32
32
|
/** A phase as plans and reports name it: `P4`. */
|
|
33
33
|
export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
|
|
34
34
|
|
|
35
|
+
/** The widest range a cell may span; a wider one is a typo, not a plan. */
|
|
36
|
+
const MAX_RANGE = 50
|
|
37
|
+
|
|
38
|
+
/** The ids from `first` to `last`, both included; empty when the range runs backwards or spans `MAX_RANGE` or more. */
|
|
39
|
+
export const spannedPhaseIds = (first: number, last: number): string[] =>
|
|
40
|
+
first > last || last - first >= MAX_RANGE
|
|
41
|
+
? []
|
|
42
|
+
: Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
|
|
43
|
+
|
|
35
44
|
/** Phases as plans and reports name them, joined by `separator`: `P3 P4`, `P19 ∥ P20`. */
|
|
36
45
|
export const phaseLabels = (ids: readonly string[], separator: string): string => ids.map(phaseLabel).join(separator)
|
|
37
46
|
|
|
@@ -39,22 +48,32 @@ const PHASES_COLUMN = 3
|
|
|
39
48
|
const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
|
|
40
49
|
const ENTRY_END = new Set([';', '.'])
|
|
41
50
|
|
|
42
|
-
type PhaseIdPatterns = { leading: RegExp; afterConnector: RegExp; connector: RegExp }
|
|
51
|
+
type PhaseIdPatterns = { leading: RegExp; rangeEnd: RegExp; afterConnector: RegExp; connector: RegExp }
|
|
43
52
|
|
|
44
53
|
const phaseIdPatterns = (language: PlanLanguage): PhaseIdPatterns => {
|
|
45
54
|
const connectors = PLAN_VOCABULARY[language].phaseConnectors.join('|')
|
|
46
55
|
const id = `(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`
|
|
47
56
|
return {
|
|
48
57
|
leading: new RegExp(`^\\s*(?:(?:${connectors})\\s+)*${id}`),
|
|
58
|
+
rangeEnd: new RegExp(`^\\s*[–-]\\s*(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+)\\b`),
|
|
49
59
|
afterConnector: new RegExp(`^\\s*(?:${connectors})\\s`),
|
|
50
60
|
connector: new RegExp(`\\b(?:${connectors})\\s+${id}`, 'g'),
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/** The ids an entry starting with `leading` names: the one id, or every id of a range `P17–P25` it opens. */
|
|
65
|
+
const leadingIds = (clause: string, leading: RegExpMatchArray, patterns: PhaseIdPatterns): string[] => {
|
|
66
|
+
const first = leading[1]
|
|
67
|
+
const end = clause.slice(leading[0].length).match(patterns.rangeEnd)
|
|
68
|
+
if (!end || !/^\d+$/.test(first)) return [first]
|
|
69
|
+
const spanned = spannedPhaseIds(Number(first), Number(end[1]))
|
|
70
|
+
return spanned.length > 0 ? spanned : [first]
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
/**
|
|
55
74
|
* The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
|
|
56
75
|
* sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
|
|
57
|
-
* with an id is running (`P1 (#<number>), P2 (#<number>)`). A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
|
|
76
|
+
* with an id is running (`P1 (#<number>), P2 (#<number>)`); an entry `P17–P25` names every phase it spans. A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
|
|
58
77
|
* P6 startet neu`) is prose.
|
|
59
78
|
*/
|
|
60
79
|
const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
@@ -69,7 +88,7 @@ const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
|
69
88
|
const leading = clause.match(patterns.leading)
|
|
70
89
|
const startsEntry: boolean = entryStart || listRunning || patterns.afterConnector.test(clause)
|
|
71
90
|
const counts: boolean = leading !== null && startsEntry
|
|
72
|
-
if (leading && counts) ids.push(leading
|
|
91
|
+
if (leading && counts) ids.push(...leadingIds(clause, leading, patterns))
|
|
73
92
|
ids.push(...[...clause.matchAll(patterns.connector)].map((match) => match[1]))
|
|
74
93
|
listRunning ||= counts
|
|
75
94
|
}
|
|
@@ -111,7 +130,8 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
|
|
|
111
130
|
/**
|
|
112
131
|
* The lanes of the sprint document's lanes table (`## Spuren` in `de`) in table order. A row whose
|
|
113
132
|
* first cell names a lane in backticks belongs to that lane; a `↳` row without a name continues
|
|
114
|
-
* the lane above it.
|
|
133
|
+
* the lane above it. A cleaned-up lane stays closed through its `↳` rows, but a later row that names it again
|
|
134
|
+
* without the mark starts the lane anew: the closed rows are history and drop out.
|
|
115
135
|
*/
|
|
116
136
|
export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
|
|
117
137
|
const lanes = new Map<string, SprintLane>()
|
|
@@ -123,7 +143,12 @@ export const parseSprintLanes = (markdown: string, language: PlanLanguage): Spri
|
|
|
123
143
|
if (!name) continue
|
|
124
144
|
const port = Number.parseInt(cells[1] ?? '', 10)
|
|
125
145
|
const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
|
|
126
|
-
|
|
146
|
+
const marked = (cells[0] ?? '').includes(laneClosed)
|
|
147
|
+
if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
|
|
148
|
+
lane.closed = false
|
|
149
|
+
lane.steps = []
|
|
150
|
+
}
|
|
151
|
+
lane.closed ||= marked
|
|
127
152
|
lane.steps.push(withoutRepeats(lane, toStep(cells, phaseId)))
|
|
128
153
|
lanes.set(name, lane)
|
|
129
154
|
previous = name
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { endsPhase, isOpenCheckboxLine, isReviewGateLine, type PhaseHeading, phaseHeadings } from './phases'
|
|
3
|
+
|
|
4
|
+
/** Characters a task's short name may have, so the herd shows it whole. */
|
|
5
|
+
export const SHORT_NAME_LIMIT = 40
|
|
6
|
+
|
|
7
|
+
/** Characters a phase title may have; the title is the phase's name. */
|
|
8
|
+
export const PHASE_TITLE_LIMIT = 50
|
|
9
|
+
|
|
10
|
+
export const SHORT_NAME_PROBLEM = { missing: 'missing', long: 'long' } as const
|
|
11
|
+
|
|
12
|
+
/** What is wrong with an open task's short name: none at all, or one of `length` characters over the limit. */
|
|
13
|
+
export type ShortNameProblem =
|
|
14
|
+
| { problem: typeof SHORT_NAME_PROBLEM.missing }
|
|
15
|
+
| { problem: typeof SHORT_NAME_PROBLEM.long; length: number }
|
|
16
|
+
|
|
17
|
+
/** An open task (1-based line) of phase `phase` whose short name is missing or too long. */
|
|
18
|
+
export type ShortNameFinding = { line: number; phase: string } & ShortNameProblem
|
|
19
|
+
|
|
20
|
+
/** A phase heading whose title has `length` characters, over the limit. */
|
|
21
|
+
export type LongPhaseTitle = PhaseHeading & { length: number }
|
|
22
|
+
|
|
23
|
+
/** `- [ ] **Name**: …`, the name in group 1. */
|
|
24
|
+
const SHORT_NAME = /^\s*[-*]\s+\[ \]\s+\*\*(.+?)\*\*:/
|
|
25
|
+
|
|
26
|
+
const characterCount = (text: string): number => [...text].length
|
|
27
|
+
|
|
28
|
+
const shortNameProblem = (line: string): ShortNameProblem | null => {
|
|
29
|
+
const name = line.match(SHORT_NAME)?.[1]
|
|
30
|
+
if (name === undefined) return { problem: SHORT_NAME_PROBLEM.missing }
|
|
31
|
+
const length = characterCount(name)
|
|
32
|
+
return length > SHORT_NAME_LIMIT ? { problem: SHORT_NAME_PROBLEM.long, length } : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The open tasks a change wrote (a line not in `previousLines`) that do not start with `**Name**:` or whose name is
|
|
37
|
+
* longer than `SHORT_NAME_LIMIT`. Only tasks of a phase section before its review gate count: a gate item keeps its
|
|
38
|
+
* own form.
|
|
39
|
+
*/
|
|
40
|
+
export const shortNameFindings = (
|
|
41
|
+
markdown: string,
|
|
42
|
+
language: PlanLanguage,
|
|
43
|
+
previousLines: ReadonlySet<string>,
|
|
44
|
+
): ShortNameFinding[] => {
|
|
45
|
+
const headings = new Map(phaseHeadings(markdown, language).map((heading) => [heading.line, heading.id]))
|
|
46
|
+
const found: ShortNameFinding[] = []
|
|
47
|
+
let phase: string | null = null
|
|
48
|
+
for (const [index, line] of markdown.split('\n').entries()) {
|
|
49
|
+
const heading = headings.get(index + 1)
|
|
50
|
+
if (heading !== undefined) phase = heading
|
|
51
|
+
else if (endsPhase(line) || isReviewGateLine(line)) phase = null
|
|
52
|
+
else if (phase !== null && isOpenCheckboxLine(line) && !previousLines.has(line)) {
|
|
53
|
+
const problem = shortNameProblem(line)
|
|
54
|
+
if (problem) found.push({ line: index + 1, phase, ...problem })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return found
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The phase headings a change wrote whose title is longer than `PHASE_TITLE_LIMIT`. */
|
|
61
|
+
export const longPhaseTitles = (
|
|
62
|
+
markdown: string,
|
|
63
|
+
language: PlanLanguage,
|
|
64
|
+
previousLines: ReadonlySet<string>,
|
|
65
|
+
): LongPhaseTitle[] => {
|
|
66
|
+
const lines = markdown.split('\n')
|
|
67
|
+
return phaseHeadings(markdown, language)
|
|
68
|
+
.filter(({ line }) => !previousLines.has(lines[line - 1] ?? ''))
|
|
69
|
+
.map((heading) => ({ ...heading, length: characterCount(heading.title) }))
|
|
70
|
+
.filter(({ length }) => length > PHASE_TITLE_LIMIT)
|
|
71
|
+
}
|
|
@@ -26,3 +26,7 @@ const UNSAFE_TERMINAL_TEXT = new RegExp(
|
|
|
26
26
|
* on the terminal. Apply it where such text enters, before any styling is added.
|
|
27
27
|
*/
|
|
28
28
|
export const terminalSafe = (text: string): string => text.replace(UNSAFE_TERMINAL_TEXT, '')
|
|
29
|
+
|
|
30
|
+
/** The text cut to `max` characters with an ellipsis as the last one; text that fits stays as it is. */
|
|
31
|
+
export const ellipsize = (text: string, max: number): string =>
|
|
32
|
+
text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
2
|
+
import { firstTableWithHeader, sectionLines } from './markdown'
|
|
3
|
+
import { terminalSafe } from './terminal-text'
|
|
4
|
+
|
|
5
|
+
/** `file` for a file or an Artifact that was sent, `question` for a dialog that ended unanswered. */
|
|
6
|
+
export const WAITING_KIND = { file: 'file', question: 'question' } as const
|
|
7
|
+
|
|
8
|
+
export type WaitingKind = (typeof WAITING_KIND)[keyof typeof WAITING_KIND]
|
|
9
|
+
|
|
10
|
+
/** One row of the board's section of what waits on the user: when it began (as written), what it is, whether it is open. */
|
|
11
|
+
export type WaitingItem = {
|
|
12
|
+
since: string
|
|
13
|
+
item: string
|
|
14
|
+
open: boolean
|
|
15
|
+
/** A `file:` address the item links to, for a sent file; absent for a row of the board. */
|
|
16
|
+
url?: string
|
|
17
|
+
/** Absent for a row of the board. */
|
|
18
|
+
kind?: WaitingKind
|
|
19
|
+
/** The id of the record that closes it (`jh waiting close`); not shown. */
|
|
20
|
+
id?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const oneLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
|
|
24
|
+
|
|
25
|
+
/** An empty status counts as open: a row nobody closed is not dropped in silence. */
|
|
26
|
+
const isOpen = (status: string, language: PlanLanguage): boolean => {
|
|
27
|
+
const lower = status.toLowerCase()
|
|
28
|
+
return lower === '' || WAITING_VOCABULARY[language].open.some((word) => lower.startsWith(word))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
|
|
33
|
+
* found by their header names in any order; the origin column is not read. A table without the since, item or status
|
|
34
|
+
* column, a section without a table and a row without an item give nothing.
|
|
35
|
+
*/
|
|
36
|
+
export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
|
|
37
|
+
const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
|
|
38
|
+
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
39
|
+
const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
|
|
40
|
+
const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
|
|
41
|
+
if (positions.includes(-1)) return []
|
|
42
|
+
const [since, item, status] = positions
|
|
43
|
+
return rows.flatMap((cells) => {
|
|
44
|
+
const text = oneLine(cells[item] ?? '')
|
|
45
|
+
return text === ''
|
|
46
|
+
? []
|
|
47
|
+
: [{ since: oneLine(cells[since] ?? ''), item: text, open: isOpen(cells[status] ?? '', language) }]
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The open items, in the order of the board. */
|
|
52
|
+
export const openWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] =>
|
|
53
|
+
readWaitingItems(board, language).filter(({ open }) => open)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { sep } from 'node:path'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
5
|
+
import type { TaskPhase } from './current-task'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A task an agent is working on: its index among the phase's checkboxes, who works on it (null while only one agent
|
|
9
|
+
* is active in the lane), whether the herd only guesses it from words, and when the agent began it (null for a guess).
|
|
10
|
+
*/
|
|
11
|
+
export type ActiveTask = { index: number; agent: string | null; guessed: boolean; startedAt: number | null }
|
|
12
|
+
|
|
13
|
+
/** A task an agent reports that no open task of the plan names. */
|
|
14
|
+
export type UnplannedTask = { text: string; agent: string | null }
|
|
15
|
+
|
|
16
|
+
export type Assignment = { byPhase: ReadonlyMap<string, ActiveTask[]>; unplanned: UnplannedTask[] }
|
|
17
|
+
|
|
18
|
+
const openTasksOf = (phases: readonly TaskPhase[]): OpenPlanTask[] =>
|
|
19
|
+
phases.flatMap(({ id, items }) =>
|
|
20
|
+
items.flatMap((item, taskIndex) => (item.done ? [] : [{ phaseId: id, taskIndex, text: item.text }])),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lays the reported `signals` over the plan's open tasks: each task an agent named is active, grouped by phase in the
|
|
25
|
+
* order of the signals, two agents on one task share it; a signal that names no open task is unplanned. Agent text is
|
|
26
|
+
* made terminal safe here, and the agent is named only when more than one agent reports.
|
|
27
|
+
*/
|
|
28
|
+
export const assignSignals = (phases: readonly TaskPhase[], signals: readonly TaskSignal[]): Assignment => {
|
|
29
|
+
const named = new Set(signals.map(({ agent }) => terminalSafe(agent))).size > 1
|
|
30
|
+
const nameOf = (agent: string): string | null => (named ? terminalSafe(agent) : null)
|
|
31
|
+
const byPhase = new Map<string, ActiveTask[]>()
|
|
32
|
+
const unplanned: UnplannedTask[] = []
|
|
33
|
+
for (const { signal, task } of matchSignals(openTasksOf(phases), signals)) {
|
|
34
|
+
if (task === null) {
|
|
35
|
+
unplanned.push({ text: terminalSafe(signal.text), agent: nameOf(signal.agent) })
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
const tasks = byPhase.get(task.phaseId) ?? []
|
|
39
|
+
const shared = tasks.find(({ index }) => index === task.taskIndex)
|
|
40
|
+
const agent = nameOf(signal.agent)
|
|
41
|
+
if (shared) shared.agent = shared.agent && agent ? `${shared.agent}, ${agent}` : shared.agent
|
|
42
|
+
else tasks.push({ index: task.taskIndex, agent, guessed: false, startedAt: signal.startedAt })
|
|
43
|
+
byPhase.set(task.phaseId, tasks)
|
|
44
|
+
}
|
|
45
|
+
return { byPhase, unplanned }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The signals still believed: a `jh task` record counts only while it is younger than the lane's last tick, since a
|
|
50
|
+
* ticked box is the better witness that the agent moved on; Claude's own task list closes itself. Without a tick, all stay.
|
|
51
|
+
*/
|
|
52
|
+
export const freshSignals = (signals: readonly TaskSignal[], lastTickAt: number | null): TaskSignal[] =>
|
|
53
|
+
signals.filter(({ source, startedAt }) => source !== 'jh' || lastTickAt === null || startedAt >= lastTickAt)
|
|
54
|
+
|
|
55
|
+
/** The lane a directory belongs to: the first directory below `worktrees` it lies in; null anywhere else. */
|
|
56
|
+
export const laneOfDirectory = (directory: string, worktrees: string): string | null => {
|
|
57
|
+
const prefix = worktrees.endsWith(sep) ? worktrees : `${worktrees}${sep}`
|
|
58
|
+
if (!directory.startsWith(prefix)) return null
|
|
59
|
+
return directory.slice(prefix.length).split(sep)[0] || null
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
2
|
+
import { padVisible } from '../terminal/text'
|
|
3
|
+
import { textWidth } from '../terminal/width'
|
|
4
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
5
|
+
import { type Appointment, readAppointmentTable } from '../watch/appointments'
|
|
6
|
+
import { shortTitle } from './task-name'
|
|
7
|
+
import type { BlockItem } from './waiting-click'
|
|
8
|
+
|
|
9
|
+
/** An open appointment as the herd shows it: `label` is the weekday and the time, `Mon 09:03` (the weekday is English whatever the board's language); `key` is the watcher's. */
|
|
10
|
+
export type AppointmentRow = { key: string; label: string; action: string }
|
|
11
|
+
|
|
12
|
+
/** The appointment block: the open rows, the earliest first. */
|
|
13
|
+
export type AppointmentBlock = { rows: AppointmentRow[] }
|
|
14
|
+
|
|
15
|
+
const APPOINTMENT_MARK = '◷'
|
|
16
|
+
|
|
17
|
+
const WEEKDAY_LOCALE = 'en-US'
|
|
18
|
+
|
|
19
|
+
const weekdayOf = (when: Date): string => new Intl.DateTimeFormat(WEEKDAY_LOCALE, { weekday: 'short' }).format(when)
|
|
20
|
+
|
|
21
|
+
const rowOf = ({ when, action, key }: Appointment): AppointmentRow => ({
|
|
22
|
+
key,
|
|
23
|
+
label: `${weekdayOf(when)} ${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())}`,
|
|
24
|
+
action,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The open appointments of the board's section, read as the watcher reads them, the earliest first. A done row, a
|
|
29
|
+
* row whose time cannot be read and a board without the section give no row.
|
|
30
|
+
*/
|
|
31
|
+
export const appointmentRows = (board: string, language: PlanLanguage, now: Date): AppointmentRow[] =>
|
|
32
|
+
readAppointmentTable(board, language, now)
|
|
33
|
+
.appointments.filter(({ open }) => open)
|
|
34
|
+
.sort((left, right) => left.when.getTime() - right.when.getTime())
|
|
35
|
+
.map(rowOf)
|
|
36
|
+
|
|
37
|
+
/** The block of the board's open appointments, as the herd shows them. */
|
|
38
|
+
export const appointmentBlock = (board: string, language: PlanLanguage, now: Date): AppointmentBlock => ({
|
|
39
|
+
rows: appointmentRows(board, language, now),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const appointmentKey = ({ key }: AppointmentRow): string => `appointment>${key}`
|
|
43
|
+
|
|
44
|
+
/** The rows as the block draws them; a click on one only expands it, so they carry no address and no question. */
|
|
45
|
+
export const appointmentItems = (rows: readonly AppointmentRow[]): BlockItem[] => {
|
|
46
|
+
const timeWidth = Math.max(0, ...rows.map(({ label }) => textWidth(label)))
|
|
47
|
+
return rows.map((row) => ({
|
|
48
|
+
key: appointmentKey(row),
|
|
49
|
+
mark: APPOINTMENT_MARK,
|
|
50
|
+
lead: padVisible(row.label, timeWidth),
|
|
51
|
+
text: row.action,
|
|
52
|
+
title: shortTitle(row.action),
|
|
53
|
+
url: null,
|
|
54
|
+
ask: null,
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The keys of all rows, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
|
|
59
|
+
export const appointmentKeys = (rows: readonly AppointmentRow[]): string[] => rows.map(appointmentKey)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
|
+
import { resolveCoordinatorName } from '../context/coordinator-name'
|
|
3
|
+
import { laneSenderFor } from '../settled/send'
|
|
4
|
+
import { type HerdrRunner, parseAgentList } from '../terminal/herdr'
|
|
5
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
6
|
+
import { ASK_KIND, type AskTarget, boardRowKey } from './ask-target'
|
|
7
|
+
import { errorText } from './error-text'
|
|
8
|
+
import { isQuestionId } from './question-records'
|
|
9
|
+
import { TEXTS } from './texts'
|
|
10
|
+
|
|
11
|
+
/** What `askCoordinator` needs from the outside: the dialog language, the terminal, the repository and herdr. */
|
|
12
|
+
export type AskDependencies = {
|
|
13
|
+
language: PlanLanguage
|
|
14
|
+
adapter: TerminalAdapter
|
|
15
|
+
/** The repository's directory name, from which the coordinator's herdr name follows. */
|
|
16
|
+
repository: string
|
|
17
|
+
run: HerdrRunner
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type AskSentences = { question: (id: string) => string; boardRow: (key: string) => string }
|
|
21
|
+
|
|
22
|
+
const ASK_LINES: Record<PlanLanguage, AskSentences> = {
|
|
23
|
+
[PLAN_LANGUAGE.de]: {
|
|
24
|
+
question: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting).`,
|
|
25
|
+
boardRow: (key) => `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).`,
|
|
26
|
+
},
|
|
27
|
+
[PLAN_LANGUAGE.en]: {
|
|
28
|
+
question: (id) => `Ask me the open question ${id} now (jh waiting).`,
|
|
29
|
+
boardRow: (key) => `Ask me the open board row \`${key}\` now (jh waiting).`,
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The line sent to the coordinator: a fixed sentence that carries the id of a question, or the sanitised key of a
|
|
35
|
+
* board row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
|
|
36
|
+
* the file it reads. The caller checks the id with `isQuestionId`.
|
|
37
|
+
*/
|
|
38
|
+
export const askLine = (target: AskTarget, language: PlanLanguage): string =>
|
|
39
|
+
target.kind === ASK_KIND.question
|
|
40
|
+
? ASK_LINES[language].question(target.id)
|
|
41
|
+
: ASK_LINES[language].boardRow(boardRowKey(target.since, target.item))
|
|
42
|
+
|
|
43
|
+
const isAskable = (target: AskTarget): boolean =>
|
|
44
|
+
target.kind === ASK_KIND.question ? isQuestionId(target.id) : boardRowKey(target.since, target.item) !== ''
|
|
45
|
+
|
|
46
|
+
const subjectOf = (target: AskTarget): string =>
|
|
47
|
+
target.kind === ASK_KIND.question ? `question ${target.id}` : `board row ${boardRowKey(target.since, target.item)}`
|
|
48
|
+
|
|
49
|
+
const agentNamesOf = (run: HerdrRunner): string[] => parseAgentList(run(['agent', 'list'])).map(({ name }) => name)
|
|
50
|
+
|
|
51
|
+
/** The coordinator's herdr name, or why there is none to reach; without a terminal the sender gives the reason. */
|
|
52
|
+
const coordinatorOf = ({ adapter, repository, run }: AskDependencies): { name: string } | { reason: string } => {
|
|
53
|
+
if (adapter === TERMINAL_ADAPTER.none) return { name: resolveCoordinatorName(repository, []) }
|
|
54
|
+
try {
|
|
55
|
+
const names = agentNamesOf(run)
|
|
56
|
+
const name = resolveCoordinatorName(repository, names)
|
|
57
|
+
return names.includes(name) ? { name } : { reason: `no agent named ${name}` }
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { reason: errorText(error) }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Asks the coordinator, by `herdr agent prompt`, to put the open question or board row `target` to the user. A
|
|
65
|
+
* coordinator that is working gets the line too: herdr queues it like a message in the middle of a turn. Returns the
|
|
66
|
+
* footer line: who was asked, or why nobody was.
|
|
67
|
+
*/
|
|
68
|
+
export const askCoordinator = (target: AskTarget, dependencies: AskDependencies): string => {
|
|
69
|
+
if (!isAskable(target)) return TEXTS.notAsked('nothing to ask')
|
|
70
|
+
const coordinator = coordinatorOf(dependencies)
|
|
71
|
+
if ('reason' in coordinator) return TEXTS.notAsked(coordinator.reason)
|
|
72
|
+
const failure = laneSenderFor(dependencies.adapter, dependencies.run)(
|
|
73
|
+
coordinator.name,
|
|
74
|
+
askLine(target, dependencies.language),
|
|
75
|
+
)
|
|
76
|
+
return failure === null ? TEXTS.asked(coordinator.name, subjectOf(target)) : TEXTS.notAsked(failure)
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** What the coordinator can be asked to put to the user: an open dialog question, or an open row of the board. */
|
|
4
|
+
export const ASK_KIND = { question: 'question', boardRow: 'boardRow' } as const
|
|
5
|
+
|
|
6
|
+
export type AskTarget =
|
|
7
|
+
| { kind: typeof ASK_KIND.question; id: string }
|
|
8
|
+
| { kind: typeof ASK_KIND.boardRow; since: string; item: string }
|
|
9
|
+
|
|
10
|
+
/** The longest key a prompt line carries for a board row, the ellipsis included. */
|
|
11
|
+
export const MAX_ROW_KEY_LENGTH = 120
|
|
12
|
+
|
|
13
|
+
const ROW_KEY_SEPARATOR = ' · '
|
|
14
|
+
|
|
15
|
+
const safeCell = (text: string): string => terminalSafe(text.replace(/\s+/g, ' ')).replaceAll('`', "'").trim()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The key of a board row as it goes into a prompt line: since and item on one line, free of control characters and
|
|
19
|
+
* backticks (the line quotes the key in backticks), cut to `MAX_ROW_KEY_LENGTH`. The text comes from the board, so
|
|
20
|
+
* it is never trusted as it is.
|
|
21
|
+
*/
|
|
22
|
+
export const boardRowKey = (since: string, item: string): string => {
|
|
23
|
+
const cells = [safeCell(since), safeCell(item)].filter((cell) => cell !== '')
|
|
24
|
+
return ellipsize(cells.join(ROW_KEY_SEPARATOR), MAX_ROW_KEY_LENGTH)
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Milliseconds a child gets to end after a signal before it is killed. */
|
|
2
|
+
export const GRACE_MS = 2000
|
|
3
|
+
|
|
4
|
+
export type StopperDeps = {
|
|
5
|
+
kill: (signal: NodeJS.Signals) => void
|
|
6
|
+
setTimer: (run: () => void, ms: number) => unknown
|
|
7
|
+
clearTimer: (handle: unknown) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const STOP_REQUEST = { forwarded: 'forwarded', killed: 'killed' } as const
|
|
11
|
+
|
|
12
|
+
export type StopRequest = (typeof STOP_REQUEST)[keyof typeof STOP_REQUEST]
|
|
13
|
+
|
|
14
|
+
export type Stopper = {
|
|
15
|
+
/** The first call forwards `signal` and arms the kill; any further call kills at once. */
|
|
16
|
+
request: (signal: NodeJS.Signals) => StopRequest
|
|
17
|
+
/** The child has ended: nothing is left to kill. */
|
|
18
|
+
ended: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stops one child: the signal goes to it, and a child that ignores it is killed once `GRACE_MS` have passed, so a
|
|
23
|
+
* supervisor never waits on a view that does not end.
|
|
24
|
+
*/
|
|
25
|
+
export const stopperOf = ({ kill, setTimer, clearTimer }: StopperDeps): Stopper => {
|
|
26
|
+
let timer: unknown
|
|
27
|
+
let requested = false
|
|
28
|
+
|
|
29
|
+
const send = (signal: NodeJS.Signals): void => {
|
|
30
|
+
try {
|
|
31
|
+
kill(signal)
|
|
32
|
+
} catch {
|
|
33
|
+
// the child is already gone
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
request: (signal) => {
|
|
39
|
+
if (requested) {
|
|
40
|
+
clearTimer(timer)
|
|
41
|
+
send('SIGKILL')
|
|
42
|
+
return STOP_REQUEST.killed
|
|
43
|
+
}
|
|
44
|
+
requested = true
|
|
45
|
+
send(signal)
|
|
46
|
+
timer = setTimer(() => send('SIGKILL'), GRACE_MS)
|
|
47
|
+
return STOP_REQUEST.forwarded
|
|
48
|
+
},
|
|
49
|
+
ended: () => clearTimer(timer),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { readRecords, writeRecordAtomically } from '../shared/private-records'
|
|
3
|
+
|
|
4
|
+
/** A record that waits on the user until something sets `closedAt`. */
|
|
5
|
+
export type ClosableRecord = { id: string; closedAt: number | null }
|
|
6
|
+
|
|
7
|
+
export type ClosableRecordLog<T extends ClosableRecord> = {
|
|
8
|
+
/** Every record of the log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
|
|
9
|
+
read: (root: string) => T[]
|
|
10
|
+
/** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
|
|
11
|
+
close: (root: string, ids: readonly string[], now: number) => number
|
|
12
|
+
/** Marks every open record `closedAt` and returns how many it closed. */
|
|
13
|
+
closeAll: (root: string, now: number) => number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The log of `.jackherd/<directoryName>/`: records the `guard` accepts, ordered by `timeOf`. */
|
|
17
|
+
export const closableRecordLog = <T extends ClosableRecord>(
|
|
18
|
+
directoryName: string,
|
|
19
|
+
guard: (value: unknown) => value is T,
|
|
20
|
+
timeOf: (record: T) => number,
|
|
21
|
+
): ClosableRecordLog<T> => {
|
|
22
|
+
const directoryOf = (root: string): string => join(root, '.jackherd', directoryName)
|
|
23
|
+
const read = (root: string): T[] => readRecords(directoryOf(root), guard, timeOf)
|
|
24
|
+
const closeWhere = (root: string, isChosen: (record: T) => boolean, now: number): number => {
|
|
25
|
+
const open = read(root).filter((record) => record.closedAt === null && isChosen(record))
|
|
26
|
+
for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
|
|
27
|
+
return open.length
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
read,
|
|
31
|
+
close: (root, ids, now) => closeWhere(root, (record) => ids.includes(record.id), now),
|
|
32
|
+
closeAll: (root, now) => closeWhere(root, () => true, now),
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/plans/command.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { boardFor, foundLanesOf } from './board-pick'
|
|
|
6
6
|
import { ensurePlanPanes } from './ensure-panes'
|
|
7
7
|
import { mainCheckout, worktreeRoot } from './git'
|
|
8
8
|
import { fetchPullRequests } from './github'
|
|
9
|
+
import { claimOwnTerminal, startExclusive } from './herd-claim'
|
|
9
10
|
import { recallLanes, rememberLanes } from './herd-memory'
|
|
10
11
|
import { runLive } from './live'
|
|
11
12
|
import { parseOptions } from './options'
|
|
@@ -59,8 +60,8 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
59
60
|
}
|
|
60
61
|
const source = { root, sprintFile: picked.file, options, settings, terminal }
|
|
61
62
|
if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
|
|
62
|
-
else if (isSupervisedChild(process.env)) runLive(source)
|
|
63
|
-
else
|
|
63
|
+
else if (isSupervisedChild(process.env)) await startExclusive(claimOwnTerminal, () => runLive(source))
|
|
64
|
+
else process.exit(await superviseView(args, await startedKit()))
|
|
64
65
|
return 0
|
|
65
66
|
} catch (error) {
|
|
66
67
|
console.error(error instanceof Error ? error.message : String(error))
|