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
package/src/plans/texts.ts
CHANGED
|
@@ -3,13 +3,13 @@ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from '.
|
|
|
3
3
|
import { shownWithMore } from './shown-with-more'
|
|
4
4
|
import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
|
|
5
5
|
|
|
6
|
-
/** The words of an open lane:
|
|
6
|
+
/** The words of an open lane: the age of a running phase, the words before the open phases that wait and the finished ones, and the pointer of a phase with only its review gate open. */
|
|
7
7
|
export type HerdTexts = {
|
|
8
|
-
sections: { session: string; chips: string; now: string; next: string; later: string; done: string }
|
|
9
8
|
minutesAgo: (minutes: number) => string
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
next: string
|
|
10
|
+
done: string
|
|
12
11
|
reviewGate: string
|
|
12
|
+
gate: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -23,7 +23,14 @@ export type PlanTexts = {
|
|
|
23
23
|
noPlan: string
|
|
24
24
|
noWorktree: string
|
|
25
25
|
noRow: string
|
|
26
|
+
/** The heads of the blocks on top of the herd. */
|
|
27
|
+
waitingHeading: string
|
|
28
|
+
appointmentHeading: string
|
|
26
29
|
notInPlan: string
|
|
30
|
+
/** Before a task an agent reports that no task of the plan names. */
|
|
31
|
+
now: string
|
|
32
|
+
/** Before the description of a running subagent that names no phase and sits on none. */
|
|
33
|
+
subagent: string
|
|
27
34
|
/** In place of the count of a phase whose only open box is the documentation box of its review gate. */
|
|
28
35
|
allButDocs: string
|
|
29
36
|
tickedSummary: (count: number) => string
|
|
@@ -34,7 +41,13 @@ export type PlanTexts = {
|
|
|
34
41
|
herd: HerdTexts
|
|
35
42
|
checks: Record<ChecksState, string>
|
|
36
43
|
hint: string
|
|
44
|
+
/** The notice when `a` is pressed with no phase open, where the key changes nothing on screen. */
|
|
45
|
+
showDoneNeedsPhase: string
|
|
37
46
|
legend: string[]
|
|
47
|
+
/** The footer line when the coordinator could not be asked to put a question, with the reason. */
|
|
48
|
+
notAsked: (reason: string) => string
|
|
49
|
+
/** The footer line when the coordinator `name` was asked to put `subject`, `question <id>` or `board row <key>`. */
|
|
50
|
+
asked: (name: string, subject: string) => string
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/** The words of the watcher's badges and of the header notice when they are missing. */
|
|
@@ -64,6 +77,7 @@ export const boardTexts = (term: string) => ({
|
|
|
64
77
|
pageNotWritten: (reason: string) => `${term} page not written: ${reason}`,
|
|
65
78
|
pageNotOpenedReason: (reason: string) => `${term} page: ${reason}`,
|
|
66
79
|
notReadable: (reason: string) => `${term} not readable: ${reason}`,
|
|
80
|
+
notOpened: (reason: string) => `could not open: ${reason}`,
|
|
67
81
|
})
|
|
68
82
|
|
|
69
83
|
const ALL_BUT_DOCS = 'docs open'
|
|
@@ -75,18 +89,22 @@ export const TEXTS: PlanTexts = {
|
|
|
75
89
|
noPlan: 'no plan',
|
|
76
90
|
noWorktree: 'no worktree',
|
|
77
91
|
noRow: 'no row in lanes',
|
|
92
|
+
waitingHeading: 'Waiting for you',
|
|
93
|
+
appointmentHeading: 'Appointments',
|
|
78
94
|
notInPlan: 'not in the plan',
|
|
95
|
+
now: 'now:',
|
|
96
|
+
subagent: 'Subagent:',
|
|
79
97
|
allButDocs: ALL_BUT_DOCS,
|
|
80
98
|
tickedSummary: (count) => `${count} ticked · click or a lists them`,
|
|
81
99
|
draft: 'draft · ',
|
|
82
100
|
privateLink: 'private',
|
|
83
101
|
phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
|
|
84
102
|
herd: {
|
|
85
|
-
sections: { session: 'SESSION', chips: '📎', now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' },
|
|
86
103
|
minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
next: 'next',
|
|
105
|
+
done: 'done',
|
|
89
106
|
reviewGate: 'Review gate',
|
|
107
|
+
gate: '· gate',
|
|
90
108
|
},
|
|
91
109
|
planned: (phases, plans) =>
|
|
92
110
|
`planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
|
|
@@ -96,15 +114,19 @@ export const TEXTS: PlanTexts = {
|
|
|
96
114
|
[CHECKS.pending]: '… checks',
|
|
97
115
|
[CHECKS.none]: 'no checks',
|
|
98
116
|
},
|
|
99
|
-
hint: '↑↓ select →← open a
|
|
117
|
+
hint: '↑↓ select →← open a ticked tasks s html q quit ? help',
|
|
118
|
+
showDoneNeedsPhase: 'a lists the ticked tasks inside an open phase: open one with → or a click',
|
|
100
119
|
legend: [
|
|
101
|
-
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a
|
|
102
|
-
'● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · working
|
|
103
|
-
`▸ ▾ open and close ·
|
|
104
|
-
|
|
105
|
-
'↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status
|
|
106
|
-
|
|
120
|
+
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
|
|
121
|
+
'● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
|
|
122
|
+
`▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, done · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · a running review sits on the gate box that names it, in the phase it names or else the lane's current one · Subagent: <text> = a running subagent that names no phase and sits on none · next = the other open phases, as ranges · done = the finished ones · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes of an open phase one by one · s opens every lane and plan as one HTML page`,
|
|
123
|
+
`the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
|
|
124
|
+
'↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
|
|
125
|
+
'▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board or one a session left in its dialog (a click on its text only expands and folds it; [ask] at the right edge of a question asks the coordinator to put it) · it goes when the board row is closed or `jh waiting close <id>` · `Waiting for you` heads the block (click: fold; click on +n: show all, on ▴ n: five) · ◷ the appointments of the board in a grey bar below it (click: fold)',
|
|
126
|
+
'⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
|
|
107
127
|
],
|
|
128
|
+
notAsked: (reason) => `no coordinator to ask: ${reason}`,
|
|
129
|
+
asked: (name, subject) => `asked ${name} for ${subject}`,
|
|
108
130
|
}
|
|
109
131
|
|
|
110
132
|
/** The mark of a running lane the board has no row for; null for a lane it lists. */
|
|
@@ -2,8 +2,8 @@ import { isAbsolute, relative, sep } from 'node:path'
|
|
|
2
2
|
import type { SprintLane } from '../plan-format'
|
|
3
3
|
import type { LaneAgent } from '../terminal'
|
|
4
4
|
|
|
5
|
-
/** A worktree with an agent that has no row in the sprint table; `name` is its directory below the worktree directory. */
|
|
6
|
-
export type UnlistedLane = { name: string; agent: LaneAgent }
|
|
5
|
+
/** A worktree with an agent, or only a subagent of the coordinator, that has no row in the sprint table; `name` is its directory below the worktree directory. */
|
|
6
|
+
export type UnlistedLane = { name: string; agent: LaneAgent | null }
|
|
7
7
|
|
|
8
8
|
type PlacedAgent = { lane: string; agent: LaneAgent }
|
|
9
9
|
|
|
@@ -25,21 +25,26 @@ const placed = (agents: readonly LaneAgent[], worktreeDirectory: string): Placed
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The lanes that run but are missing from the sprint table: one per worktree below `worktreeDirectory` with an
|
|
28
|
-
* agent in it
|
|
29
|
-
* agent of a worktree stands for it; the
|
|
28
|
+
* agent in it, or a subagent of the coordinator at work in it (`subagentLanes`, no agent then). A worktree counts as
|
|
29
|
+
* listed when the table names it, or names any agent running in it. The first agent of a worktree stands for it; the
|
|
30
|
+
* lanes come in name order.
|
|
30
31
|
*/
|
|
31
32
|
export const unlistedLanes = (
|
|
32
33
|
agents: readonly LaneAgent[],
|
|
33
34
|
listed: ReadonlySet<string>,
|
|
34
35
|
worktreeDirectory: string,
|
|
36
|
+
subagentLanes: ReadonlySet<string> = new Set(),
|
|
35
37
|
): UnlistedLane[] => {
|
|
36
38
|
const inWorktrees = placed(agents, worktreeDirectory)
|
|
37
39
|
const listedWorktrees = new Set(
|
|
38
40
|
inWorktrees.filter(({ lane, agent }) => listed.has(lane) || listed.has(agent.name)).map(({ lane }) => lane),
|
|
39
41
|
)
|
|
40
|
-
const byLane = new Map<string, LaneAgent>()
|
|
42
|
+
const byLane = new Map<string, LaneAgent | null>()
|
|
41
43
|
for (const { lane, agent } of inWorktrees) {
|
|
42
44
|
if (!listedWorktrees.has(lane) && !byLane.has(lane)) byLane.set(lane, agent)
|
|
43
45
|
}
|
|
46
|
+
for (const lane of subagentLanes) {
|
|
47
|
+
if (!listed.has(lane) && !byLane.has(lane)) byLane.set(lane, null)
|
|
48
|
+
}
|
|
44
49
|
return [...byLane].sort(([left], [right]) => left.localeCompare(right)).map(([name, agent]) => ({ name, agent }))
|
|
45
50
|
}
|
package/src/plans/view.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
|
|
1
2
|
import { fit, plainText } from '../terminal/text'
|
|
2
3
|
import { renderSelected, type Screen } from './layout'
|
|
3
4
|
import type { Options } from './options'
|
|
@@ -20,7 +21,9 @@ const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
|
|
|
20
21
|
export const bodyRows = (screen: Screen): number =>
|
|
21
22
|
Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/** The time as `HH:MM`, in the header and in the refresh error alike: a frame differs only once a minute. */
|
|
25
|
+
export const clockOf = (locale: string, now: Date = new Date()): string =>
|
|
26
|
+
now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
|
24
27
|
|
|
25
28
|
export const renderSnapshot = (
|
|
26
29
|
snapshot: Snapshot,
|
|
@@ -37,6 +40,9 @@ export const renderSnapshot = (
|
|
|
37
40
|
planned: snapshot.planned,
|
|
38
41
|
watch: snapshot.watch,
|
|
39
42
|
watchesTickets: snapshot.watchesTickets,
|
|
43
|
+
awaiting: snapshot.waiting,
|
|
44
|
+
appointments: snapshot.appointments,
|
|
45
|
+
unplaced: snapshot.unplaced,
|
|
40
46
|
},
|
|
41
47
|
selection,
|
|
42
48
|
width,
|
|
@@ -45,24 +51,26 @@ export const renderSnapshot = (
|
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
|
|
48
|
-
options.lane
|
|
49
|
-
? laneSelection(snapshot.views[0]?.progress, snapshot.views[0]?.status.agent ?? null)
|
|
50
|
-
: overviewSelection()
|
|
54
|
+
options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
|
|
51
55
|
|
|
52
56
|
/** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
|
|
53
|
-
export const
|
|
54
|
-
const width = paneWidth()
|
|
55
|
-
const rows = bodyRows(screen)
|
|
57
|
+
export const frameOutput = (screen: Screen, top: number, width: number, rows: number): string => {
|
|
56
58
|
const body = screen.body.lines.slice(top, top + rows)
|
|
57
59
|
const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
|
|
58
60
|
const lines = [...screen.header, ...body, ...padding, ...screen.footer]
|
|
59
|
-
|
|
60
|
-
`${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`,
|
|
61
|
-
)
|
|
61
|
+
return `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/** The frame to write, or null when the terminal already shows it; a null `previous` means the terminal content is unknown. */
|
|
65
|
+
export const frameToWrite = (previous: string | null, next: string): string | null => (previous === next ? null : next)
|
|
66
|
+
|
|
67
|
+
const ARTIFACT_ADDRESS = new RegExp(ARTIFACT_LINK_KIND.pattern)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One frame as text; ANSI styling is dropped when stdout is not a terminal, and links are written out as their
|
|
71
|
+
* address, except an Artifact's, which is long and would push the rest of its line past the pane.
|
|
72
|
+
*/
|
|
65
73
|
export const frameText = (screen: Screen, isTerminal: boolean): string => {
|
|
66
74
|
const lines = [...screen.header, ...screen.body.lines, ...screen.footer]
|
|
67
|
-
return (isTerminal ? lines : lines.map(plainText)).join('\n')
|
|
75
|
+
return (isTerminal ? lines : lines.map((line) => plainText(line, (url) => !ARTIFACT_ADDRESS.test(url)))).join('\n')
|
|
68
76
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
|
|
2
|
+
import { fit, padVisible, wrap } from '../terminal/text'
|
|
3
|
+
import { textWidth } from '../terminal/width'
|
|
4
|
+
import { ASK_KIND, type AskTarget } from './ask-target'
|
|
5
|
+
import { plain } from './layout-phase'
|
|
6
|
+
import { MOUSE_BUTTON, type MouseEvent } from './selection'
|
|
7
|
+
import { shortTitle } from './task-name'
|
|
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'
|
|
68
|
+
/** The bar and the blank that start every line of a for-you row. */
|
|
69
|
+
export const WAITING_MARK = '▌'
|
|
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
|
|
74
|
+
const FILE_MARK = '📎'
|
|
75
|
+
const QUESTION_MARK = '?'
|
|
76
|
+
const OPENABLE = /^(https?|file):\/\//
|
|
77
|
+
|
|
78
|
+
/** The key of a row in the herd's expanded set: the record's id, else its text. */
|
|
79
|
+
export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
|
|
80
|
+
|
|
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)
|
|
83
|
+
|
|
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
|
|
147
|
+
const expanded = cut && expandedKeys.has(key)
|
|
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 }
|
|
152
|
+
}
|
|
153
|
+
|
|
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. */
|
|
167
|
+
export const waitingRows = (
|
|
168
|
+
items: readonly WaitingItem[],
|
|
169
|
+
expandedKeys: ReadonlySet<string>,
|
|
170
|
+
width: number,
|
|
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)
|
|
175
|
+
|
|
176
|
+
const rowAt = (
|
|
177
|
+
rows: readonly BlockRow[],
|
|
178
|
+
firstLine: number,
|
|
179
|
+
line: number,
|
|
180
|
+
): { row: BlockRow; onFirstLine: boolean } | null => {
|
|
181
|
+
let first = firstLine
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
if (line < first + row.lines.length) return line >= first ? { row, onFirstLine: line === first } : null
|
|
184
|
+
first += row.lines.length
|
|
185
|
+
}
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
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 }
|
|
195
|
+
if (url) return cut && !expanded ? { toggle: key } : { open: url }
|
|
196
|
+
return cut ? { toggle: key } : null
|
|
197
|
+
}
|
|
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
|
+
|
|
205
|
+
/**
|
|
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.
|
|
213
|
+
*/
|
|
214
|
+
export const blockClick = (
|
|
215
|
+
rows: readonly BlockRow[],
|
|
216
|
+
firstLine: number,
|
|
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 => {
|
|
225
|
+
if (!pressed || button !== MOUSE_BUTTON.left) return null
|
|
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
|
|
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
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
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'
|
|
5
|
+
import { openInBrowser } from '../terminal/open'
|
|
6
|
+
import { runGh } from '../watch/gh'
|
|
7
|
+
import { embedExternalImages, fetchImage } from './embed'
|
|
8
|
+
import { rewriteImages } from './images'
|
|
9
|
+
import { mediaTypeOf } from './open'
|
|
10
|
+
import { asksForHelp, PREVIEW_USAGE, parsePreviewOptions } from './options'
|
|
11
|
+
import { previewPage } from './page'
|
|
12
|
+
import { renderMarkdown, repositorySlugOf } from './render'
|
|
13
|
+
|
|
14
|
+
type PackageFacts = { root: string; name: string }
|
|
15
|
+
|
|
16
|
+
const packageOf = (file: string): PackageFacts | null => {
|
|
17
|
+
for (let directory = dirname(file); ; directory = dirname(directory)) {
|
|
18
|
+
const manifest = join(directory, 'package.json')
|
|
19
|
+
if (existsSync(manifest)) {
|
|
20
|
+
const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
|
|
21
|
+
if (typeof name === 'string') return { root: directory, name }
|
|
22
|
+
}
|
|
23
|
+
if (dirname(directory) === directory) return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const remoteSlugOf = (directory: string): string | null => {
|
|
28
|
+
const result = Bun.spawnSync(['git', '-C', directory, 'remote', 'get-url', 'origin'], {
|
|
29
|
+
stdout: 'pipe',
|
|
30
|
+
stderr: 'pipe',
|
|
31
|
+
})
|
|
32
|
+
return result.exitCode === 0 ? repositorySlugOf(result.stdout.toString()) : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `file` is a regular file whose real path, links followed, lies under the real path of `root`. */
|
|
36
|
+
const isRegularFileUnder = (root: string, file: string): boolean => {
|
|
37
|
+
if (!existsSync(file)) return false
|
|
38
|
+
return realpathSync(file).startsWith(`${realpathSync(root)}/`) && statSync(file).isFile()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The image at `path` under `root` as a data address; null when it is missing, no image, no regular file, or outside
|
|
43
|
+
* `root` once links are followed, so a link in the working tree cannot put a file from elsewhere into the page.
|
|
44
|
+
*/
|
|
45
|
+
export const inlineImage = (root: string, path: string): string | null => {
|
|
46
|
+
const file = resolve(root, path)
|
|
47
|
+
const type = mediaTypeOf(file)
|
|
48
|
+
if (type === null || !file.startsWith(`${root}/`) || !isRegularFileUnder(root, file)) return null
|
|
49
|
+
return `data:${type};base64,${readFileSync(file).toString('base64')}`
|
|
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
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `jh preview <markdown-file> [--out <path>] [--open]`: renders the file through GitHub's markdown API and writes a
|
|
76
|
+
* page that shows it in GitHub and in npm style, light and dark, and at phone width. The package's own images come
|
|
77
|
+
* from the working tree, so the preview shows what is not published yet. External images (badges) are embedded as data addresses, so the page is complete where it may load nothing from outside; one that cannot be fetched stays linked, with one warning. Prints the path of the page; `--open`
|
|
78
|
+
* opens it. `--help` prints the usage. Exit 0 written, 1 failed (`gh` missing or not signed in, file unreadable).
|
|
79
|
+
*/
|
|
80
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
81
|
+
if (asksForHelp(args)) {
|
|
82
|
+
console.log(PREVIEW_USAGE)
|
|
83
|
+
return 0
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const options = parsePreviewOptions(args)
|
|
87
|
+
const file = resolve(options.file)
|
|
88
|
+
if (!existsSync(file)) throw new Error(`${options.file} does not exist`)
|
|
89
|
+
const facts = packageOf(file)
|
|
90
|
+
const html = await renderMarkdown(readFileSync(file, 'utf8'), remoteSlugOf(dirname(file)), dirname(file), runGh)
|
|
91
|
+
const own = facts ?? { root: dirname(file), name: '' }
|
|
92
|
+
const local = rewriteImages(html, own.name, (path) => inlineImage(own.root, path))
|
|
93
|
+
const { html: rewritten, failed } = await embedExternalImages(local, fetchImage)
|
|
94
|
+
if (failed > 0)
|
|
95
|
+
console.error(`${failed} external ${failed === 1 ? 'image' : 'images'} could not be embedded and stay linked`)
|
|
96
|
+
const out = writePreviewPage(file, previewPage({ title: basename(file), html: rewritten }), options.out)
|
|
97
|
+
console.log(out)
|
|
98
|
+
const reason = options.open ? openInBrowser(out) : null
|
|
99
|
+
if (reason) console.error(`could not open the page: ${reason}`)
|
|
100
|
+
return 0
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
103
|
+
return 1
|
|
104
|
+
}
|
|
105
|
+
}
|