jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- 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/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- 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-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sentences of a lane prompt in one language, after `/orchestrate` § 1. The opening words and the column name of
|
|
5
|
+
* a write set come from the vocabulary; the report file keeps its `## Questions` heading in every language, since the
|
|
6
|
+
* watcher finds the section by that word.
|
|
7
|
+
*/
|
|
8
|
+
export type LanePromptTexts = {
|
|
9
|
+
answersTo: string
|
|
10
|
+
phasesIntro: string
|
|
11
|
+
noWrites: string
|
|
12
|
+
wholeRepository: string
|
|
13
|
+
editInside: string
|
|
14
|
+
writeTools: string
|
|
15
|
+
port: (port: number) => string
|
|
16
|
+
noPort: string
|
|
17
|
+
blocking: string
|
|
18
|
+
mustNot: string
|
|
19
|
+
commit: string
|
|
20
|
+
report: (reportPath: string) => string
|
|
21
|
+
textBlock: string
|
|
22
|
+
secret: string
|
|
23
|
+
askRule: string
|
|
24
|
+
approvalGate: string
|
|
25
|
+
helper: (helperName: string) => string
|
|
26
|
+
helperPurpose: string
|
|
27
|
+
start: string
|
|
28
|
+
restart: (handoverPath: string) => string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
32
|
+
[PLAN_LANGUAGE.en]: {
|
|
33
|
+
answersTo: 'You answer to the coordinator, not to the user.',
|
|
34
|
+
phasesIntro: 'Your plan and phases:',
|
|
35
|
+
noWrites: 'no Writes set in the plan',
|
|
36
|
+
wholeRepository: 'the whole repository',
|
|
37
|
+
editInside:
|
|
38
|
+
'Nothing outside the Writes set of your phases is edited, except the task lines of your phases in the plan: tick a finished one right away, by an Edit of exactly that line.',
|
|
39
|
+
writeTools:
|
|
40
|
+
'Write code and tests with Write and Edit, never through a here-document or a script in Bash: such a call shows the reviewer no diff, and an unquoted or unreadable one can stop at a guard.',
|
|
41
|
+
port: (port) =>
|
|
42
|
+
`Port ${port}: the coordinator runs your dev server in its own pane; look at your visible changes there (\`/implement\` Step 4b) and never start a server in your own shell.`,
|
|
43
|
+
noPort:
|
|
44
|
+
'No port: this lane has no dev server of its own. Never start a server in your own shell; ask the coordinator when you need one.',
|
|
45
|
+
blocking:
|
|
46
|
+
'Mark every question of yours `blocking` or `not blocking`. A blocking one stops the step it decides; for a not blocking one leave a `TODO` at its spot and work on, building neither your recommendation nor another option as decided.',
|
|
47
|
+
mustNot:
|
|
48
|
+
'Without the user’s approval you push no database migration and write nothing against a shared environment (stage, production); you never run `git reset` or `git stash` on shared refs.',
|
|
49
|
+
commit: 'Ask before every commit (`/pre-commit`); no push, PR or merge without approval.',
|
|
50
|
+
report: (reportPath) =>
|
|
51
|
+
`The report at the end of each step goes to \`${reportPath}\` in the worktree, in the short form: one line of diff stat, one line per check with its numbers, open points, questions as a text block under the heading \`## Questions\` (whatever the report’s language: the watcher finds the section by that word and shows it whole); everything else is in the plan, by reference.`,
|
|
52
|
+
textBlock:
|
|
53
|
+
'Every question to the coordinator is a text block at the end of your turn, never a dialog (`AskUserQuestion`), also where a skill or a project rule asks for one, the commit confirmation of `/pre-commit` included: the question with the situation, the problem and what each option leads to, in everyday words (the user may not know your terms), the options, your recommendation, the evidence at `file:line`.',
|
|
54
|
+
secret:
|
|
55
|
+
'A value only the user knows (a secret, password, token or one-time code) never passes through you, your arguments, your output or a file you write: ask the coordinator for the step as a question.',
|
|
56
|
+
askRule:
|
|
57
|
+
'A tool that asks for approval on every call (an ask rule, such as a database query tool or a project CLI) gets as few calls as possible: bundle the steps into one call (one script, one query) instead of one call per step.',
|
|
58
|
+
approvalGate:
|
|
59
|
+
'A call that would open a permission dialog waits for the coordinator: the approval gate holds it and the coordinator asks the user. Denied with “approval pending”, retry the same call once the coordinator says it is approved; denied by the user, do not retry it.',
|
|
60
|
+
helper: (helperName) =>
|
|
61
|
+
`A helper agent in a pane of your own (a measurement, a probe session) is announced to the coordinator before it starts, as a question at the end of your turn: what it does, how long, and which temporary settings it uses. Once approved, name it \`${helperName}\` (\`herdr agent rename\`), so the watcher watches it with your lane, and close it yourself when it is done: \`/exit\`, then \`herdr pane close\`, and remove every temporary file it needed.`,
|
|
62
|
+
helperPurpose: '<purpose>',
|
|
63
|
+
start: 'Start: run `/implement` for your phases, in the order above.',
|
|
64
|
+
restart: (handoverPath) => `Start: read \`${handoverPath}\` first and continue with the next step it names.`,
|
|
65
|
+
},
|
|
66
|
+
[PLAN_LANGUAGE.de]: {
|
|
67
|
+
answersTo: 'Du antwortest dem Koordinator, nicht dem User.',
|
|
68
|
+
phasesIntro: 'Dein Plan und deine Phasen:',
|
|
69
|
+
noWrites: 'keine Schreibt-Menge im Plan',
|
|
70
|
+
wholeRepository: 'das ganze Repository',
|
|
71
|
+
editInside:
|
|
72
|
+
'Außerhalb der Schreibt-Menge deiner Phasen änderst du nichts, außer den Aufgabenzeilen deiner Phasen im Plan: eine erledigte hakst du sofort ab, per Edit auf genau diese Zeile.',
|
|
73
|
+
writeTools:
|
|
74
|
+
'Code und Tests schreibst du mit Write und Edit, nie per Heredoc oder Skript in Bash: ein solcher Aufruf zeigt dem Prüfer keinen Diff, und ein ungequoteter oder unlesbarer kann an einem Wächter hängen bleiben.',
|
|
75
|
+
port: (port) =>
|
|
76
|
+
`Port ${port}: Der Koordinator betreibt deinen Dev-Server in seinem eigenen Pane; deine sichtbaren Änderungen siehst du dort an (\`/implement\` Step 4b) und startest nie einen Server in deiner eigenen Shell.`,
|
|
77
|
+
noPort:
|
|
78
|
+
'Kein Port: Diese Spur hat keinen eigenen Dev-Server. Starte nie einen Server in deiner eigenen Shell; brauchst du einen, frag den Koordinator.',
|
|
79
|
+
blocking:
|
|
80
|
+
'Markiere jede deiner Fragen mit `blocking` oder `not blocking`. Eine blockierende hält den Schritt an, den sie entscheidet; bei einer nicht blockierenden lässt du an ihrer Stelle ein `TODO` und arbeitest weiter, ohne deine Empfehlung oder eine andere Option als entschieden zu bauen.',
|
|
81
|
+
mustNot:
|
|
82
|
+
'Ohne Freigabe des Users pushst du keine Datenbank-Migration und schreibst nichts gegen eine geteilte Umgebung (Stage, Produktion); `git reset` und `git stash` auf geteilten Refs führst du nie aus.',
|
|
83
|
+
commit: 'Vor jedem Commit fragst du (`/pre-commit`); kein Push, kein PR und kein Merge ohne Freigabe.',
|
|
84
|
+
report: (reportPath) =>
|
|
85
|
+
`Den Bericht am Ende jedes Schritts schreibst du nach \`${reportPath}\` im Worktree, in der Kurzform: eine Zeile Diff-Stat, eine Zeile je Check mit seinen Zahlen, offene Punkte, Fragen als Textblock unter der Überschrift \`## Questions\` (in jeder Berichtssprache: der Watcher findet den Abschnitt an diesem Wort und zeigt ihn ganz); alles andere steht im Plan, per Verweis.`,
|
|
86
|
+
textBlock:
|
|
87
|
+
'Jede Frage an den Koordinator ist ein Textblock am Ende deines Turns, nie ein Dialog (`AskUserQuestion`), auch wo ein Skill oder eine Projektregel einen verlangt, die Commit-Bestätigung von `/pre-commit` eingeschlossen: die Frage mit der Lage, dem Problem und wohin jede Option führt, in Alltagsworten (der User kennt deine Begriffe vielleicht nicht), die Optionen, deine Empfehlung, der Beleg als `datei:zeile`.',
|
|
88
|
+
secret:
|
|
89
|
+
'Ein Wert, den nur der User kennt (ein Geheimnis, Passwort, Token oder Einmalcode), läuft nie über dich, deine Argumente, deine Ausgabe oder eine Datei, die du schreibst: bitte den Koordinator mit einer Frage um diesen Schritt.',
|
|
90
|
+
askRule:
|
|
91
|
+
'Ein Werkzeug, das bei jedem Aufruf um Freigabe fragt (eine Ask-Regel, etwa ein Datenbank-Abfragewerkzeug oder eine Projekt-CLI), bekommt so wenige Aufrufe wie möglich: bündle die Schritte in einen Aufruf (ein Skript, eine Abfrage) statt eines Aufrufs je Schritt.',
|
|
92
|
+
approvalGate:
|
|
93
|
+
'Ein Aufruf, der einen Erlaubnis-Dialog öffnen würde, wartet auf den Koordinator: Das Approval-Gate hält ihn an, und der Koordinator fragt den User. Abgelehnt mit „approval pending“: denselben Aufruf wiederholen, sobald der Koordinator ihn als freigegeben meldet; vom User abgelehnt: nicht wiederholen.',
|
|
94
|
+
helper: (helperName) =>
|
|
95
|
+
`Einen Helfer-Agenten in einem eigenen Pane (eine Messung, eine Probesitzung) meldest du dem Koordinator an, bevor er startet, als Frage am Ende deines Turns: was er tut, wie lange, und welche temporären Einstellungen er nutzt. Nach der Freigabe benennst du ihn \`${helperName}\` (\`herdr agent rename\`), damit der Watcher ihn mit deiner Spur beobachtet, und schließt ihn selbst, wenn er fertig ist: \`/exit\`, dann \`herdr pane close\`, und entferne jede temporäre Datei, die er brauchte.`,
|
|
96
|
+
helperPurpose: '<zweck>',
|
|
97
|
+
start: 'Start: Führe `/implement` für deine Phasen aus, in der Reihenfolge oben.',
|
|
98
|
+
restart: (handoverPath) =>
|
|
99
|
+
`Start: Lies zuerst \`${handoverPath}\` und mach mit dem nächsten Schritt weiter, den sie nennt.`,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { asksForHelp } from '../preview/options'
|
|
3
|
+
import { CLI_NAME } from '../version/release'
|
|
4
|
+
import { type MermaidProblem, mermaidProblems } from './problems'
|
|
5
|
+
|
|
6
|
+
const USAGE = `usage: ${CLI_NAME} mermaid <file.md>
|
|
7
|
+
|
|
8
|
+
Checks every \`\`\`mermaid block of the file for edge labels GitHub does not draw (a label between the dashes,
|
|
9
|
+
a pipe label without quotes). Run it on a pull request body before gh pr create or gh pr edit.
|
|
10
|
+
Exit 0 clean, 1 with problems or a wrong command line.`
|
|
11
|
+
|
|
12
|
+
/** One line per problem, `file:line: reason`, then the offending text indented. */
|
|
13
|
+
const problemLines = (file: string, problems: readonly MermaidProblem[]): string[] =>
|
|
14
|
+
problems.flatMap(({ line, reason, text }) => [`${file}:${line}: ${reason}`, ` ${text}`])
|
|
15
|
+
|
|
16
|
+
export const run = (args: string[]): number => {
|
|
17
|
+
if (asksForHelp(args)) {
|
|
18
|
+
console.log(USAGE)
|
|
19
|
+
return 0
|
|
20
|
+
}
|
|
21
|
+
const [file, ...rest] = args
|
|
22
|
+
if (file === undefined || rest.length > 0) {
|
|
23
|
+
console.error(USAGE)
|
|
24
|
+
return 1
|
|
25
|
+
}
|
|
26
|
+
let text: string
|
|
27
|
+
try {
|
|
28
|
+
text = readFileSync(file, 'utf8')
|
|
29
|
+
} catch {
|
|
30
|
+
console.error(`cannot read ${file}`)
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
const problems = mermaidProblems(text)
|
|
34
|
+
for (const line of problemLines(file, problems)) console.error(line)
|
|
35
|
+
return problems.length === 0 ? 0 : 1
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** A line of a Mermaid block that GitHub fails to draw: its 1-based line in the text, the line itself and why. */
|
|
2
|
+
export type MermaidProblem = { line: number; text: string; reason: string }
|
|
3
|
+
|
|
4
|
+
const FENCE = /^\s*(```|~~~)\s*(\S*)/
|
|
5
|
+
const FLOWCHART = /^\s*(?:flowchart|graph)\b/
|
|
6
|
+
/** A label written between the dashes of the link, `a -- text --> b`: a `--` inside the text ends the link early. */
|
|
7
|
+
const LABEL_BETWEEN_DASHES = /\s(?:--|==)\s+\S.*?\s(?:-->|---|==>)(?:\s|$)/
|
|
8
|
+
/** A pipe label that does not open with a quote: a comma, colon or parenthesis in it breaks the parser. */
|
|
9
|
+
const UNQUOTED_PIPE_LABEL = /(?:-->|---|-\.->|==>)\|[^"|]/
|
|
10
|
+
|
|
11
|
+
const REASON = {
|
|
12
|
+
betweenDashes: 'edge label between dashes: write -->|"label"|',
|
|
13
|
+
unquoted: 'edge label without quotes: write -->|"label"|',
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
const reasonOf = (text: string): string | null => {
|
|
17
|
+
if (LABEL_BETWEEN_DASHES.test(text)) return REASON.betweenDashes
|
|
18
|
+
return UNQUOTED_PIPE_LABEL.test(text) ? REASON.unquoted : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The edge labels in the flowcharts of `markdown` that GitHub does not draw: ones written between the dashes and
|
|
23
|
+
* pipe labels without quotes. Only fenced blocks marked `mermaid` are read (a block never closed runs to the end of the
|
|
24
|
+
* text); the form that always parses is `-->|"label"|`.
|
|
25
|
+
*/
|
|
26
|
+
export const mermaidProblems = (markdown: string): MermaidProblem[] => {
|
|
27
|
+
const problems: MermaidProblem[] = []
|
|
28
|
+
let fence: string | null = null
|
|
29
|
+
let isFlowchart = false
|
|
30
|
+
markdown.split('\n').forEach((text, index) => {
|
|
31
|
+
const opening = FENCE.exec(text)
|
|
32
|
+
if (fence === null) {
|
|
33
|
+
if (opening?.[2] === 'mermaid') {
|
|
34
|
+
fence = opening[1] ?? '```'
|
|
35
|
+
isFlowchart = false
|
|
36
|
+
}
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (opening && opening[1] === fence && opening[2] === '') {
|
|
40
|
+
fence = null
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (FLOWCHART.test(text)) isFlowchart = true
|
|
44
|
+
const reason = isFlowchart ? reasonOf(text) : null
|
|
45
|
+
if (reason) problems.push({ line: index + 1, text: text.trim(), reason })
|
|
46
|
+
})
|
|
47
|
+
return problems
|
|
48
|
+
}
|
package/src/plan-format/clock.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export const MS_PER_MINUTE = 60_000
|
|
2
2
|
|
|
3
|
+
export const DAY_MS = 86_400_000
|
|
4
|
+
|
|
3
5
|
/** A day, month, hour or minute with a leading zero: `7` → `07`. */
|
|
4
6
|
export const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
7
|
+
|
|
8
|
+
/** A time as the for-you rows show it: `DD.MM. HH:MM`. */
|
|
9
|
+
export const clockText = (at: number): string => {
|
|
10
|
+
const date = new Date(at)
|
|
11
|
+
return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
|
|
12
|
+
}
|
|
@@ -81,10 +81,10 @@ const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency,
|
|
|
81
81
|
/**
|
|
82
82
|
* A path as the cell writes it, cut to the directory a glob character starts in (`scripts/*.ts` is `scripts`,
|
|
83
83
|
* `.claude/**` is `.claude`, `*.md` is the root `''`) and without a trailing slash; null for a backticked entry with a
|
|
84
|
-
* space in it, which is a command.
|
|
84
|
+
* space in it, which is a command, and for one with `[]`, which is a config key (`watch.sources[].label`) and no glob.
|
|
85
85
|
*/
|
|
86
86
|
const pathOf = (entry: string): string | null => {
|
|
87
|
-
if (/\s/.test(entry)) return null
|
|
87
|
+
if (/\s/.test(entry) || entry.includes('[]')) return null
|
|
88
88
|
const glob = entry.search(GLOB_CHARACTER)
|
|
89
89
|
const literal = glob === -1 ? entry : entry.slice(0, Math.max(entry.lastIndexOf('/', glob), 0))
|
|
90
90
|
const path = literal.replace(/^\.\//, '').replace(/\/+$/, '')
|
|
@@ -200,18 +200,21 @@ const longestChain = (chains: readonly string[][]): string[] =>
|
|
|
200
200
|
const mergeSets = (sets: readonly (string[] | null)[]): string[] | null =>
|
|
201
201
|
sets.some((set) => set === null) ? null : sets.flatMap((set) => set ?? [])
|
|
202
202
|
|
|
203
|
-
|
|
204
|
-
const writeSetOf = (
|
|
203
|
+
const writeSetWithin = (
|
|
205
204
|
row: PhaseDependency,
|
|
206
205
|
byId: ReadonlyMap<string, PhaseDependency>,
|
|
207
206
|
chain: readonly string[],
|
|
208
207
|
): string[] | null => {
|
|
209
208
|
if (chain.includes(row.id)) return null
|
|
210
209
|
const targets = row.writesLike.map((id) => byId.get(id))
|
|
211
|
-
const like = mergeSets(targets.map((target) => (target ?
|
|
210
|
+
const like = mergeSets(targets.map((target) => (target ? writeSetWithin(target, byId, [...chain, row.id]) : null)))
|
|
212
211
|
return like === null ? null : [...row.writes, ...like]
|
|
213
212
|
}
|
|
214
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
|
+
|
|
215
218
|
const overlaps = (left: string, right: string): boolean =>
|
|
216
219
|
left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
217
220
|
|
|
@@ -228,7 +231,7 @@ const collides = (first: Candidate, second: Candidate): boolean =>
|
|
|
228
231
|
sharesMarker(first, second) || first.writes.some((path) => second.writes.some((other) => overlaps(path, other)))
|
|
229
232
|
|
|
230
233
|
const candidateOf = (row: PhaseDependency, byId: ReadonlyMap<string, PhaseDependency>): Candidate[] => {
|
|
231
|
-
const writes = writeSetOf(row, byId
|
|
234
|
+
const writes = writeSetOf(row, byId)
|
|
232
235
|
if (writes === null || writes.length === 0) return []
|
|
233
236
|
const releases = [...new Set([...row.releases, ...releasedByFiles(writes)])]
|
|
234
237
|
return [{ id: row.id, writes, serialMarkers: row.serialMarkers, releases }]
|
package/src/plan-format/index.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -2,15 +2,24 @@ import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
|
2
2
|
import { firstTableWithHeader, sectionLines } from './markdown'
|
|
3
3
|
import { terminalSafe } from './terminal-text'
|
|
4
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
|
+
|
|
5
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. */
|
|
6
11
|
export type WaitingItem = {
|
|
7
12
|
since: string
|
|
8
13
|
item: string
|
|
9
14
|
open: boolean
|
|
15
|
+
/** The board row's origin cell, one line; absent when the table has no such column or the cell is empty. */
|
|
16
|
+
origin?: string
|
|
17
|
+
/** What the herd shows before the item (the label of the source the row came from); set by the herd, never read from the board. */
|
|
18
|
+
prefix?: string
|
|
10
19
|
/** A `file:` address the item links to, for a sent file; absent for a row of the board. */
|
|
11
20
|
url?: string
|
|
12
|
-
/**
|
|
13
|
-
kind?:
|
|
21
|
+
/** Absent for a row of the board. */
|
|
22
|
+
kind?: WaitingKind
|
|
14
23
|
/** The id of the record that closes it (`jh waiting close`); not shown. */
|
|
15
24
|
id?: string
|
|
16
25
|
}
|
|
@@ -25,7 +34,7 @@ const isOpen = (status: string, language: PlanLanguage): boolean => {
|
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
36
|
* The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
|
|
28
|
-
* found by their header names in any order; the origin column is not
|
|
37
|
+
* found by their header names in any order; the origin column is read when the table has it and the cell is not empty. A table without the since, item or status
|
|
29
38
|
* column, a section without a table and a row without an item give nothing.
|
|
30
39
|
*/
|
|
31
40
|
export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
|
|
@@ -35,11 +44,20 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
|
|
|
35
44
|
const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
|
|
36
45
|
if (positions.includes(-1)) return []
|
|
37
46
|
const [since, item, status] = positions
|
|
47
|
+
const originPosition = header.indexOf(columns.origin)
|
|
38
48
|
return rows.flatMap((cells) => {
|
|
39
49
|
const text = oneLine(cells[item] ?? '')
|
|
50
|
+
const origin = oneLine(cells[originPosition] ?? '')
|
|
40
51
|
return text === ''
|
|
41
52
|
? []
|
|
42
|
-
: [
|
|
53
|
+
: [
|
|
54
|
+
{
|
|
55
|
+
since: oneLine(cells[since] ?? ''),
|
|
56
|
+
item: text,
|
|
57
|
+
open: isOpen(cells[status] ?? '', language),
|
|
58
|
+
...(origin === '' ? {} : { origin }),
|
|
59
|
+
},
|
|
60
|
+
]
|
|
43
61
|
})
|
|
44
62
|
}
|
|
45
63
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DAY_MS, 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 { ASK_KIND } from './ask-target'
|
|
7
|
+
import { DONE_KIND } from './row-targets'
|
|
8
|
+
import { shortTitle } from './task-name'
|
|
9
|
+
import { type BlockButton, type BlockItem, DONE_BUTTON, MOVE_BUTTON, RUN_BUTTON } from './waiting-click'
|
|
10
|
+
|
|
11
|
+
/** 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. */
|
|
12
|
+
export type AppointmentRow = { key: string; when: string; label: string; action: string }
|
|
13
|
+
|
|
14
|
+
/** The appointment block: the open rows, the earliest first. */
|
|
15
|
+
export type AppointmentBlock = { rows: AppointmentRow[] }
|
|
16
|
+
|
|
17
|
+
const APPOINTMENT_MARK = '◷'
|
|
18
|
+
|
|
19
|
+
const WEEKDAY_LOCALE = 'en-US'
|
|
20
|
+
|
|
21
|
+
const weekdayOf = (when: Date): string => new Intl.DateTimeFormat(WEEKDAY_LOCALE, { weekday: 'short' }).format(when)
|
|
22
|
+
|
|
23
|
+
const DATE_FROM_DAYS_AHEAD = 7
|
|
24
|
+
|
|
25
|
+
const startOfDay = (when: Date): number => new Date(when.getFullYear(), when.getMonth(), when.getDate()).getTime()
|
|
26
|
+
|
|
27
|
+
const daysAhead = (when: Date, now: Date): number => Math.round((startOfDay(when) - startOfDay(now)) / DAY_MS)
|
|
28
|
+
|
|
29
|
+
/** The weekday names a day within the week; from a week on it is the date, `13.01.`, since the weekday would repeat. */
|
|
30
|
+
const dayOf = (when: Date, now: Date): string =>
|
|
31
|
+
daysAhead(when, now) >= DATE_FROM_DAYS_AHEAD
|
|
32
|
+
? `${twoDigits(when.getDate())}.${twoDigits(when.getMonth() + 1)}.`
|
|
33
|
+
: weekdayOf(when)
|
|
34
|
+
|
|
35
|
+
const rowOf =
|
|
36
|
+
(now: Date) =>
|
|
37
|
+
({ when, whenText, action, key }: Appointment): AppointmentRow => ({
|
|
38
|
+
key,
|
|
39
|
+
when: whenText,
|
|
40
|
+
label: `${dayOf(when, now)} ${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())}`,
|
|
41
|
+
action,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The open appointments of the board's section, read as the watcher reads them, the earliest first. A done row, a
|
|
46
|
+
* row whose time cannot be read and a board without the section give no row.
|
|
47
|
+
*/
|
|
48
|
+
export const appointmentRows = (board: string, language: PlanLanguage, now: Date): AppointmentRow[] =>
|
|
49
|
+
readAppointmentTable(board, language, now)
|
|
50
|
+
.appointments.filter(({ open }) => open)
|
|
51
|
+
.sort((left, right) => left.when.getTime() - right.when.getTime())
|
|
52
|
+
.map(rowOf(now))
|
|
53
|
+
|
|
54
|
+
/** The block of the board's open appointments, as the herd shows them. */
|
|
55
|
+
export const appointmentBlock = (board: string, language: PlanLanguage, now: Date): AppointmentBlock => ({
|
|
56
|
+
rows: appointmentRows(board, language, now),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const appointmentKey = ({ key }: AppointmentRow): string => `appointment>${key}`
|
|
60
|
+
|
|
61
|
+
const buttonsOf = ({ when, action }: AppointmentRow): BlockButton[] => [
|
|
62
|
+
{ label: DONE_BUTTON, click: { done: { kind: DONE_KIND.appointment, when, action } } },
|
|
63
|
+
{ label: RUN_BUTTON, click: { ask: { kind: ASK_KIND.appointmentRun, when, action } } },
|
|
64
|
+
{ label: MOVE_BUTTON, click: { ask: { kind: ASK_KIND.appointmentMove, when, action } } },
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
/** The rows as the block draws them; a click on the row only expands it, the buttons close it, run it or move it. */
|
|
68
|
+
export const appointmentItems = (rows: readonly AppointmentRow[]): BlockItem[] => {
|
|
69
|
+
const timeWidth = Math.max(0, ...rows.map(({ label }) => textWidth(label)))
|
|
70
|
+
return rows.map((row) => ({
|
|
71
|
+
key: appointmentKey(row),
|
|
72
|
+
mark: APPOINTMENT_MARK,
|
|
73
|
+
lead: padVisible(row.label, timeWidth),
|
|
74
|
+
text: row.action,
|
|
75
|
+
title: shortTitle(row.action),
|
|
76
|
+
url: null,
|
|
77
|
+
ask: null,
|
|
78
|
+
buttons: buttonsOf(row),
|
|
79
|
+
}))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The keys of all rows, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
|
|
83
|
+
export const appointmentKeys = (rows: readonly AppointmentRow[]): string[] => rows.map(appointmentKey)
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
/** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
|
|
21
|
+
type AskSentences = Record<AskTarget['kind'], (subject: string, scope: string) => string>
|
|
22
|
+
|
|
23
|
+
const ASK_LINES: Record<PlanLanguage, AskSentences> = {
|
|
24
|
+
[PLAN_LANGUAGE.de]: {
|
|
25
|
+
[ASK_KIND.question]: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting).`,
|
|
26
|
+
[ASK_KIND.boardRow]: (key) => `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).`,
|
|
27
|
+
[ASK_KIND.appointmentRun]: (key) => `Führe den Termin \`${key}\` jetzt aus, unter den Freigaben, die er braucht.`,
|
|
28
|
+
[ASK_KIND.appointmentMove]: (key) =>
|
|
29
|
+
`Frag den User per Dialog nach der neuen Zeit für den Termin \`${key}\` und trag sie ins Board ein (verschobene Zeile = neuer Termin).`,
|
|
30
|
+
[ASK_KIND.sourceEntry]: (title, source) =>
|
|
31
|
+
`Sieh dir die Meldung \`${title}\` der Quelle \`${source}\` an und stell mir, falls nötig, die Frage dazu (jh waiting).`,
|
|
32
|
+
},
|
|
33
|
+
[PLAN_LANGUAGE.en]: {
|
|
34
|
+
[ASK_KIND.question]: (id) => `Ask me the open question ${id} now (jh waiting).`,
|
|
35
|
+
[ASK_KIND.boardRow]: (key) => `Ask me the open board row \`${key}\` now (jh waiting).`,
|
|
36
|
+
[ASK_KIND.appointmentRun]: (key) => `Run the appointment \`${key}\` now, under the approvals it needs.`,
|
|
37
|
+
[ASK_KIND.appointmentMove]: (key) =>
|
|
38
|
+
`Ask the user in a dialog for the new time of the appointment \`${key}\` and write it into the board (a moved row is a new appointment).`,
|
|
39
|
+
[ASK_KIND.sourceEntry]: (title, source) =>
|
|
40
|
+
`Look at the report \`${title}\` of the source \`${source}\` and, if it needs it, put the question about it to me (jh waiting).`,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the sentence names: the id of a question, else the sanitised key of the row (`boardRowKey`). */
|
|
45
|
+
const subjectKeyOf = (target: AskTarget): string => {
|
|
46
|
+
if (target.kind === ASK_KIND.question) return target.id
|
|
47
|
+
if (target.kind === ASK_KIND.sourceEntry) return boardRowKey('', target.title)
|
|
48
|
+
return target.kind === ASK_KIND.boardRow
|
|
49
|
+
? boardRowKey(target.since, target.item)
|
|
50
|
+
: boardRowKey(target.when, target.action)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The source of an entry, sanitised like the key; `''` for every other target. */
|
|
54
|
+
const scopeOf = (target: AskTarget): string =>
|
|
55
|
+
target.kind === ASK_KIND.sourceEntry ? boardRowKey('', target.source) : ''
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The line sent to the coordinator: a fixed sentence that carries the id of a question, or the sanitised key of a
|
|
59
|
+
* row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
|
|
60
|
+
* the file it reads. The caller checks the id with `isQuestionId`.
|
|
61
|
+
*/
|
|
62
|
+
export const askLine = (target: AskTarget, language: PlanLanguage): string =>
|
|
63
|
+
ASK_LINES[language][target.kind](subjectKeyOf(target), scopeOf(target))
|
|
64
|
+
|
|
65
|
+
const isAskable = (target: AskTarget): boolean =>
|
|
66
|
+
target.kind === ASK_KIND.question
|
|
67
|
+
? isQuestionId(target.id)
|
|
68
|
+
: subjectKeyOf(target) !== '' && (target.kind !== ASK_KIND.sourceEntry || scopeOf(target) !== '')
|
|
69
|
+
|
|
70
|
+
const SUBJECT_NAMES: Record<AskTarget['kind'], string> = {
|
|
71
|
+
[ASK_KIND.question]: 'question',
|
|
72
|
+
[ASK_KIND.boardRow]: 'board row',
|
|
73
|
+
[ASK_KIND.appointmentRun]: 'appointment',
|
|
74
|
+
[ASK_KIND.appointmentMove]: 'appointment',
|
|
75
|
+
[ASK_KIND.sourceEntry]: 'report',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const subjectOf = (target: AskTarget): string => {
|
|
79
|
+
const scope = scopeOf(target)
|
|
80
|
+
return `${SUBJECT_NAMES[target.kind]} ${subjectKeyOf(target)}${scope === '' ? '' : ` of ${scope}`}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const agentNamesOf = (run: HerdrRunner): string[] => parseAgentList(run(['agent', 'list'])).map(({ name }) => name)
|
|
84
|
+
|
|
85
|
+
/** The coordinator's herdr name, or why there is none to reach; without a terminal the sender gives the reason. */
|
|
86
|
+
const coordinatorOf = ({ adapter, repository, run }: AskDependencies): { name: string } | { reason: string } => {
|
|
87
|
+
if (adapter === TERMINAL_ADAPTER.none) return { name: resolveCoordinatorName(repository, []) }
|
|
88
|
+
try {
|
|
89
|
+
const names = agentNamesOf(run)
|
|
90
|
+
const name = resolveCoordinatorName(repository, names)
|
|
91
|
+
return names.includes(name) ? { name } : { reason: `no agent named ${name}` }
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { reason: errorText(error) }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Asks the coordinator, by `herdr agent prompt`, to put the open question or board row `target` to the user. A
|
|
99
|
+
* coordinator that is working gets the line too: herdr queues it like a message in the middle of a turn. Returns the
|
|
100
|
+
* footer line: who was asked, or why nobody was.
|
|
101
|
+
*/
|
|
102
|
+
export const askCoordinator = (target: AskTarget, dependencies: AskDependencies): string => {
|
|
103
|
+
if (!isAskable(target)) return TEXTS.notAsked('nothing to ask')
|
|
104
|
+
const coordinator = coordinatorOf(dependencies)
|
|
105
|
+
if ('reason' in coordinator) return TEXTS.notAsked(coordinator.reason)
|
|
106
|
+
const failure = laneSenderFor(dependencies.adapter, dependencies.run)(
|
|
107
|
+
coordinator.name,
|
|
108
|
+
askLine(target, dependencies.language),
|
|
109
|
+
)
|
|
110
|
+
return failure === null ? TEXTS.asked(coordinator.name, subjectOf(target)) : TEXTS.notAsked(failure)
|
|
111
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the coordinator can be asked: to put an open dialog question or an open board row to the user, to run an
|
|
5
|
+
* appointment now, or to ask the user for a new time for it.
|
|
6
|
+
*/
|
|
7
|
+
export const ASK_KIND = {
|
|
8
|
+
question: 'question',
|
|
9
|
+
boardRow: 'boardRow',
|
|
10
|
+
appointmentRun: 'appointmentRun',
|
|
11
|
+
appointmentMove: 'appointmentMove',
|
|
12
|
+
sourceEntry: 'sourceEntry',
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export type AskTarget =
|
|
16
|
+
| { kind: typeof ASK_KIND.question; id: string }
|
|
17
|
+
| { kind: typeof ASK_KIND.boardRow; since: string; item: string }
|
|
18
|
+
| { kind: typeof ASK_KIND.appointmentRun | typeof ASK_KIND.appointmentMove; when: string; action: string }
|
|
19
|
+
| { kind: typeof ASK_KIND.sourceEntry; source: string; title: string }
|
|
20
|
+
|
|
21
|
+
/** The longest key a prompt line carries for a board row, the ellipsis included. */
|
|
22
|
+
export const MAX_ROW_KEY_LENGTH = 120
|
|
23
|
+
|
|
24
|
+
const ROW_KEY_SEPARATOR = ' · '
|
|
25
|
+
|
|
26
|
+
const safeCell = (text: string): string => terminalSafe(text.replace(/\s+/g, ' ')).replaceAll('`', "'").trim()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The key of a board row as it goes into a prompt line: since and item on one line, free of control characters and
|
|
30
|
+
* backticks (the line quotes the key in backticks), cut to `MAX_ROW_KEY_LENGTH`. The text comes from the board, so
|
|
31
|
+
* it is never trusted as it is.
|
|
32
|
+
*/
|
|
33
|
+
export const boardRowKey = (since: string, item: string): string => {
|
|
34
|
+
const cells = [safeCell(since), safeCell(item)].filter((cell) => cell !== '')
|
|
35
|
+
return ellipsize(cells.join(ROW_KEY_SEPARATOR), MAX_ROW_KEY_LENGTH)
|
|
36
|
+
}
|