jackherd 1.14.0 → 1.15.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 +15 -0
- package/README.md +27 -19
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.js +24 -1
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +19 -0
- package/src/check/command.ts +1 -1
- package/src/check/file-kinds.ts +10 -8
- package/src/check/parallel-report.ts +10 -3
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +17 -5
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +56 -17
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +51 -2
- package/src/watch/lane-report.ts +57 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +4 -0
|
@@ -46,7 +46,12 @@ const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources
|
|
|
46
46
|
if (board.notice) console.error(board.notice)
|
|
47
47
|
const directory = dirname(boardRepoPathOf(root, settings, board.file))
|
|
48
48
|
return {
|
|
49
|
-
request: {
|
|
49
|
+
request: {
|
|
50
|
+
lane: options.lane,
|
|
51
|
+
handover: options.handover,
|
|
52
|
+
language: settings.planLanguage,
|
|
53
|
+
skillPrefix: settings.skillPrefix,
|
|
54
|
+
},
|
|
50
55
|
board: { text: readFileSync(board.file, 'utf8'), directory, path: relative(cwd, board.file) },
|
|
51
56
|
readPlan: readFirstIn([root, checkout]),
|
|
52
57
|
readLanePlan: readFirstIn([worktreeOf(root, settings, options.lane)]),
|
package/src/lane-prompt/input.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { isPhaseFinished } from '../plan-format/progress'
|
|
|
5
5
|
import type { PlanLanguage } from '../vocabulary'
|
|
6
6
|
import type { LanePhase, LanePlan, LanePromptInput } from './build'
|
|
7
7
|
|
|
8
|
-
/** The lane to prompt, the hand-over file of a restart (null for a fresh start)
|
|
9
|
-
export type LanePromptRequest = { lane: string; handover: string | null; language: PlanLanguage }
|
|
8
|
+
/** The lane to prompt, the hand-over file of a restart (null for a fresh start), the plan language and the project's `skillPrefix`. */
|
|
9
|
+
export type LanePromptRequest = { lane: string; handover: string | null; language: PlanLanguage; skillPrefix: string }
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* The board's text, its directory from the repository root, which its plan links are relative to, and the path a
|
|
@@ -146,12 +146,12 @@ export const lanePromptInputOf = (
|
|
|
146
146
|
const found = plansOf(steps, { main: readPlan, lane: readLanePlan }, request.language)
|
|
147
147
|
if ('reason' in found) return refused(found.reason)
|
|
148
148
|
if (found.plans.length === 0) return refused(`every phase of lane ${request.lane} is finished`)
|
|
149
|
-
const { lane, handover, language } = request
|
|
149
|
+
const { lane, handover, language, skillPrefix } = request
|
|
150
150
|
const others = lanes.filter((candidate) => candidate.name !== lane && !candidate.closed)
|
|
151
151
|
const { plans, notices } = withoutContested(found.plans, lane, others, board.directory)
|
|
152
152
|
if (plans.length === 0) {
|
|
153
153
|
return refused(`every open phase of lane ${lane} is also named by another lane: ${notices.join('; ')}`)
|
|
154
154
|
}
|
|
155
|
-
const input = { language, lane, plans, port: sprintLane.port, handover }
|
|
155
|
+
const input = { language, lane, plans, port: sprintLane.port, handover, skillPrefix }
|
|
156
156
|
return notices.length === 0 ? { kind: 'prompt', input } : { kind: 'prompt', input, notices }
|
|
157
157
|
}
|
package/src/lane-prompt/texts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
1
2
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -12,11 +13,12 @@ export type LanePromptTexts = {
|
|
|
12
13
|
wholeRepository: string
|
|
13
14
|
editInside: string
|
|
14
15
|
writeTools: string
|
|
15
|
-
port: (port: number) => string
|
|
16
|
+
port: (port: number, implementSkill: string) => string
|
|
16
17
|
noPort: string
|
|
17
18
|
blocking: string
|
|
18
19
|
mustNot: string
|
|
19
20
|
commit: string
|
|
21
|
+
taskSignals: string
|
|
20
22
|
report: (reportPath: string) => string
|
|
21
23
|
textBlock: string
|
|
22
24
|
secret: string
|
|
@@ -24,7 +26,7 @@ export type LanePromptTexts = {
|
|
|
24
26
|
approvalGate: string
|
|
25
27
|
helper: (helperName: string) => string
|
|
26
28
|
helperPurpose: string
|
|
27
|
-
start: string
|
|
29
|
+
start: (implementSkill: string) => string
|
|
28
30
|
restart: (handoverPath: string) => string
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -38,15 +40,17 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
38
40
|
'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
41
|
writeTools:
|
|
40
42
|
'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 (
|
|
43
|
+
port: (port, implementSkill) =>
|
|
44
|
+
`Port ${port}: the coordinator runs your dev server in its own pane; look at your visible changes there (\`${implementSkill}\` Step 4b) and never start a server in your own shell.`,
|
|
43
45
|
noPort:
|
|
44
46
|
'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
47
|
blocking:
|
|
46
48
|
'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
49
|
mustNot:
|
|
48
50
|
'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:
|
|
51
|
+
commit:
|
|
52
|
+
'Ask before every commit (`/pre-commit` is the way where it is installed); git (commit, push, PR, merge) goes only through the coordinator and only with approval.',
|
|
53
|
+
taskSignals: `Whatever skill builds a task, report it: \`${CLI_NAME} task start "<task>"\` before and \`${CLI_NAME} task done\` after every task. The herd shows the running task from that signal, and the watcher sees a standstill by it.`,
|
|
50
54
|
report: (reportPath) =>
|
|
51
55
|
`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
56
|
textBlock:
|
|
@@ -60,7 +64,8 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
60
64
|
helper: (helperName) =>
|
|
61
65
|
`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
66
|
helperPurpose: '<purpose>',
|
|
63
|
-
start:
|
|
67
|
+
start: (implementSkill) =>
|
|
68
|
+
`Start: build your phases in the order above, with \`${implementSkill}\` or a skill of your choice; the rules above hold for every one.`,
|
|
64
69
|
restart: (handoverPath) => `Start: read \`${handoverPath}\` first and continue with the next step it names.`,
|
|
65
70
|
},
|
|
66
71
|
[PLAN_LANGUAGE.de]: {
|
|
@@ -72,15 +77,17 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
72
77
|
'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
78
|
writeTools:
|
|
74
79
|
'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 (
|
|
80
|
+
port: (port, implementSkill) =>
|
|
81
|
+
`Port ${port}: Der Koordinator betreibt deinen Dev-Server in seinem eigenen Pane; deine sichtbaren Änderungen siehst du dort an (\`${implementSkill}\` Step 4b) und startest nie einen Server in deiner eigenen Shell.`,
|
|
77
82
|
noPort:
|
|
78
83
|
'Kein Port: Diese Spur hat keinen eigenen Dev-Server. Starte nie einen Server in deiner eigenen Shell; brauchst du einen, frag den Koordinator.',
|
|
79
84
|
blocking:
|
|
80
85
|
'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
86
|
mustNot:
|
|
82
87
|
'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:
|
|
88
|
+
commit:
|
|
89
|
+
'Vor jedem Commit fragst du (`/pre-commit` ist der Weg, wo es installiert ist); Git (Commit, Push, PR, Merge) läuft nur über den Koordinator und nur mit Freigabe.',
|
|
90
|
+
taskSignals: `Gleich mit welchem Skill eine Aufgabe gebaut wird, meldest du sie: \`${CLI_NAME} task start "<aufgabe>"\` vor und \`${CLI_NAME} task done\` nach jeder Aufgabe. Die Herde zeigt die laufende Aufgabe an diesem Signal, und der Watcher erkennt daran einen Stillstand.`,
|
|
84
91
|
report: (reportPath) =>
|
|
85
92
|
`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
93
|
textBlock:
|
|
@@ -94,7 +101,8 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
94
101
|
helper: (helperName) =>
|
|
95
102
|
`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
103
|
helperPurpose: '<zweck>',
|
|
97
|
-
start:
|
|
104
|
+
start: (implementSkill) =>
|
|
105
|
+
`Start: Baue deine Phasen in der Reihenfolge oben, mit \`${implementSkill}\` oder einem Skill deiner Wahl; die Regeln oben gelten für jeden.`,
|
|
98
106
|
restart: (handoverPath) =>
|
|
99
107
|
`Start: Lies zuerst \`${handoverPath}\` und mach mit dem nächsten Schritt weiter, den sie nennt.`,
|
|
100
108
|
},
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { findProjectRoot } from '../config/location'
|
|
5
|
+
import { readConfig } from '../config/read-config'
|
|
6
|
+
import { asksForHelp } from '../preview/options'
|
|
7
|
+
import { CLI_NAME } from '../version/release'
|
|
8
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
9
|
+
import { contractOf, type PartFinding, type PlanContract } from './contract'
|
|
10
|
+
|
|
11
|
+
const CHECK_SUBCOMMAND = 'check'
|
|
12
|
+
|
|
13
|
+
/** Exit codes of `jh plan check`: the contract is met, a part has a gap, the command line or the file is wrong. */
|
|
14
|
+
export const PLAN_CHECK_EXIT = { met: 0, gaps: 1, error: 2 } as const
|
|
15
|
+
|
|
16
|
+
export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
|
|
17
|
+
|
|
18
|
+
Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
|
|
19
|
+
row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
|
|
20
|
+
The plan language comes from .jackherd/config.json.
|
|
21
|
+
Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file.`
|
|
22
|
+
|
|
23
|
+
const where = (file: string, line: number | null): string => (line === null ? '' : ` ${file}:${line}`)
|
|
24
|
+
|
|
25
|
+
/** One line per part: `model: malformed plan.md:12 no model for P2`. */
|
|
26
|
+
export const contractLines = (file: string, { parts }: PlanContract): string[] =>
|
|
27
|
+
parts.map(({ part, status, line, message }: PartFinding) => `${part}: ${status}${where(file, line)} ${message}`)
|
|
28
|
+
|
|
29
|
+
type LanguageResult = { language: PlanLanguage } | { error: string }
|
|
30
|
+
|
|
31
|
+
const languageOf = (home: string): LanguageResult => {
|
|
32
|
+
try {
|
|
33
|
+
const root = findProjectRoot(process.cwd(), home, existsSync) ?? process.cwd()
|
|
34
|
+
return { language: readConfig([root], home).planLanguage }
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const readPlan = (file: string): string | null => {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(resolve(process.cwd(), file), 'utf8')
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fail = (message: string): number => {
|
|
49
|
+
console.error(message)
|
|
50
|
+
return PLAN_CHECK_EXIT.error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const run = (args: string[], home: string = homedir()): number => {
|
|
54
|
+
if (asksForHelp(args)) {
|
|
55
|
+
console.log(USAGE)
|
|
56
|
+
return PLAN_CHECK_EXIT.met
|
|
57
|
+
}
|
|
58
|
+
const [subcommand, file, ...rest] = args
|
|
59
|
+
if (subcommand !== CHECK_SUBCOMMAND)
|
|
60
|
+
return fail(subcommand === undefined ? USAGE : `unknown subcommand ${subcommand}\n${USAGE}`)
|
|
61
|
+
if (file === undefined || rest.length > 0) return fail(USAGE)
|
|
62
|
+
const markdown = readPlan(file)
|
|
63
|
+
if (markdown === null) return fail(`cannot read ${file}\n${USAGE}`)
|
|
64
|
+
const result = languageOf(home)
|
|
65
|
+
if ('error' in result) return fail(result.error)
|
|
66
|
+
const contract = contractOf(markdown, result.language)
|
|
67
|
+
for (const line of contractLines(file, contract)) console.log(line)
|
|
68
|
+
return contract.met ? PLAN_CHECK_EXIT.met : PLAN_CHECK_EXIT.gaps
|
|
69
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ModelRow,
|
|
3
|
+
NO_DEPENDENCY_TABLE,
|
|
4
|
+
type PhaseHeading,
|
|
5
|
+
parseDependencyTable,
|
|
6
|
+
parseModelTable,
|
|
7
|
+
parsePlanPhases,
|
|
8
|
+
phaseCount,
|
|
9
|
+
phaseHeadings,
|
|
10
|
+
phaseLabels,
|
|
11
|
+
} from '../plan-format'
|
|
12
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
13
|
+
|
|
14
|
+
/** The four parts of the plan contract. */
|
|
15
|
+
export const CONTRACT_PART = {
|
|
16
|
+
phases: 'phases',
|
|
17
|
+
tasks: 'tasks',
|
|
18
|
+
model: 'model',
|
|
19
|
+
dependencies: 'dependencies',
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
export type ContractPart = (typeof CONTRACT_PART)[keyof typeof CONTRACT_PART]
|
|
23
|
+
|
|
24
|
+
/** What the plan holds of a part: all of it, none of it, or something the readers cannot use. */
|
|
25
|
+
export const PART_STATUS = { present: 'present', missing: 'missing', malformed: 'malformed' } as const
|
|
26
|
+
|
|
27
|
+
export type PartStatus = (typeof PART_STATUS)[keyof typeof PART_STATUS]
|
|
28
|
+
|
|
29
|
+
/** `line` is the 1-based line of the first problem, or of the part's first sign; null when the plan shows none. */
|
|
30
|
+
export type PartFinding = { part: ContractPart; status: PartStatus; line: number | null; message: string }
|
|
31
|
+
|
|
32
|
+
/** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table is a note. */
|
|
33
|
+
export type PlanContract = { parts: PartFinding[]; met: boolean }
|
|
34
|
+
|
|
35
|
+
/** Fewer phases than this need no dependency table: there is nothing to put side by side. */
|
|
36
|
+
const PHASES_NEEDING_DEPENDENCIES = 2
|
|
37
|
+
|
|
38
|
+
const SUB_PHASE_SUFFIX = /[a-z]$/
|
|
39
|
+
const NO_MODEL_MARK = /^[—–-]/
|
|
40
|
+
|
|
41
|
+
/** The phase a sub-phase belongs to (`3b` is `3`); a phase without a letter is its own base. */
|
|
42
|
+
const baseOf = (id: string): string => id.replace(SUB_PHASE_SUFFIX, '')
|
|
43
|
+
|
|
44
|
+
const finding = (part: ContractPart, status: PartStatus, line: number | null, message: string): PartFinding => ({
|
|
45
|
+
part,
|
|
46
|
+
status,
|
|
47
|
+
line,
|
|
48
|
+
message,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const uniqueHeadings = (headings: readonly PhaseHeading[]): PhaseHeading[] =>
|
|
52
|
+
headings.filter(({ id }, index) => headings.findIndex((other) => other.id === id) === index)
|
|
53
|
+
|
|
54
|
+
const phasesPart = (headings: readonly PhaseHeading[]): PartFinding =>
|
|
55
|
+
headings.length === 0
|
|
56
|
+
? finding(CONTRACT_PART.phases, PART_STATUS.missing, null, 'no phase heading')
|
|
57
|
+
: finding(CONTRACT_PART.phases, PART_STATUS.present, headings[0].line, phaseCount(headings.length))
|
|
58
|
+
|
|
59
|
+
/** Present when no phase lacks the part (`ok`), else malformed at the first phase that does, naming them all. */
|
|
60
|
+
const coverage = (
|
|
61
|
+
part: ContractPart,
|
|
62
|
+
without: readonly PhaseHeading[],
|
|
63
|
+
ok: { line: number | null; message: string },
|
|
64
|
+
problem: string,
|
|
65
|
+
): PartFinding =>
|
|
66
|
+
without.length === 0
|
|
67
|
+
? finding(part, PART_STATUS.present, ok.line, ok.message)
|
|
68
|
+
: finding(
|
|
69
|
+
part,
|
|
70
|
+
PART_STATUS.malformed,
|
|
71
|
+
without[0].line,
|
|
72
|
+
`${problem} ${phaseLabels(
|
|
73
|
+
without.map(({ id }) => id),
|
|
74
|
+
', ',
|
|
75
|
+
)}`,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const tasksPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
79
|
+
if (headings.length === 0) return finding(CONTRACT_PART.tasks, PART_STATUS.missing, null, 'no phase to hold tasks')
|
|
80
|
+
const empty = new Set(
|
|
81
|
+
parsePlanPhases(markdown, language)
|
|
82
|
+
.filter(({ items }) => items.length === 0)
|
|
83
|
+
.map(({ id }) => id),
|
|
84
|
+
)
|
|
85
|
+
const without = headings.filter(({ id }) => empty.has(id))
|
|
86
|
+
return coverage(
|
|
87
|
+
CONTRACT_PART.tasks,
|
|
88
|
+
without,
|
|
89
|
+
{ line: headings[0].line, message: 'every phase has tasks' },
|
|
90
|
+
'no checkbox in',
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A row names a model, or marks the phase as one that needs none (`—`, a phase only a person does). */
|
|
95
|
+
const isDecided = ({ models, cell }: ModelRow): boolean => models.length > 0 || NO_MODEL_MARK.test(cell)
|
|
96
|
+
|
|
97
|
+
/** A phase is covered by a listed id of its own or of its base phase. */
|
|
98
|
+
const lacks = (headings: readonly PhaseHeading[], listed: ReadonlySet<string>): PhaseHeading[] =>
|
|
99
|
+
headings.filter(({ id }) => !listed.has(id) && !listed.has(baseOf(id)))
|
|
100
|
+
|
|
101
|
+
const modelPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
102
|
+
const rows = parseModelTable(markdown, language)
|
|
103
|
+
if (rows === null) return finding(CONTRACT_PART.model, PART_STATUS.missing, null, 'no model table')
|
|
104
|
+
const modelled = new Set(rows.filter(isDecided).map(({ id }) => id))
|
|
105
|
+
return coverage(
|
|
106
|
+
CONTRACT_PART.model,
|
|
107
|
+
lacks(headings, modelled),
|
|
108
|
+
{ line: rows[0]?.line ?? null, message: 'every phase has a model' },
|
|
109
|
+
'no model for',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const dependenciesPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
114
|
+
if (headings.length < PHASES_NEEDING_DEPENDENCIES)
|
|
115
|
+
return finding(CONTRACT_PART.dependencies, PART_STATUS.present, null, 'not needed with fewer than two phases')
|
|
116
|
+
const table = parseDependencyTable(markdown, language)
|
|
117
|
+
if (table === null) return finding(CONTRACT_PART.dependencies, PART_STATUS.missing, null, NO_DEPENDENCY_TABLE)
|
|
118
|
+
return coverage(
|
|
119
|
+
CONTRACT_PART.dependencies,
|
|
120
|
+
lacks(headings, new Set(table.map(({ id }) => id))),
|
|
121
|
+
{ line: null, message: 'every phase has a row' },
|
|
122
|
+
'no row for',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A missing dependency table is a note, not a gap: the phases then run one after another. */
|
|
127
|
+
export const isGap = ({ part, status }: PartFinding): boolean =>
|
|
128
|
+
status === PART_STATUS.malformed || (status === PART_STATUS.missing && part !== CONTRACT_PART.dependencies)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What a plan holds of the contract the herd, the watcher, `jh check` and a lane start read: phase headings,
|
|
132
|
+
* checkboxes in every phase, a recognised model for every phase, and (from two phases on) a dependency row for every
|
|
133
|
+
* phase. Built on the parsers those readers use, so a part is present exactly when they find it.
|
|
134
|
+
*/
|
|
135
|
+
export const contractOf = (markdown: string, language: PlanLanguage): PlanContract => {
|
|
136
|
+
const headings = uniqueHeadings(phaseHeadings(markdown, language))
|
|
137
|
+
const parts = [
|
|
138
|
+
phasesPart(headings),
|
|
139
|
+
tasksPart(markdown, language, headings),
|
|
140
|
+
modelPart(markdown, language, headings),
|
|
141
|
+
dependenciesPart(markdown, language, headings),
|
|
142
|
+
]
|
|
143
|
+
return { parts, met: !parts.some(isGap) }
|
|
144
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { PHASE_ID_PREFIX, phaseLabels, spannedPhaseIds } from './lanes'
|
|
2
|
+
import { PHASE_ID_PREFIX, phaseIdOf, phaseLabels, spannedPhaseIds } from './lanes'
|
|
3
3
|
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
5
|
import { isPhaseFinished } from './progress'
|
|
@@ -30,7 +30,6 @@ export type PhaseWithoutRow = PhaseHeading
|
|
|
30
30
|
type Candidate = { id: string; writes: string[]; serialMarkers: string[]; releases: string[] }
|
|
31
31
|
|
|
32
32
|
const ID = '(\\d+[a-z]?)'
|
|
33
|
-
const PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?${ID}\\b`, 'i')
|
|
34
33
|
const PHASE_RANGE = new RegExp(`^${PHASE_ID_PREFIX}?(\\d+)\\s*[–-]\\s*${PHASE_ID_PREFIX}?(\\d+)$`, 'i')
|
|
35
34
|
const BACKTICKED = /`([^`]+)`/g
|
|
36
35
|
const NO_VALUE = /^[—–-]?$/
|
|
@@ -41,8 +40,6 @@ const PACKAGE_SCOPE = /^@[^/]+\//
|
|
|
41
40
|
/** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
|
|
42
41
|
const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
|
|
43
42
|
|
|
44
|
-
const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
45
|
-
|
|
46
43
|
const splitOutsideParentheses = (cell: string, separator: string): string[] => {
|
|
47
44
|
const entries: string[] = []
|
|
48
45
|
let depth = 0
|
package/src/plan-format/index.ts
CHANGED
|
@@ -18,9 +18,12 @@ export * from './drift'
|
|
|
18
18
|
export * from './header'
|
|
19
19
|
export {
|
|
20
20
|
laneNameOf,
|
|
21
|
+
NO_DEPENDENCY_TABLE,
|
|
21
22
|
PHASE_ID_PREFIX,
|
|
22
23
|
type PlanStep,
|
|
23
24
|
parseSprintLanes,
|
|
25
|
+
phaseCount,
|
|
26
|
+
phaseIdOf,
|
|
24
27
|
phaseIdsOfCell,
|
|
25
28
|
phaseLabel,
|
|
26
29
|
phaseLabels,
|
|
@@ -30,6 +33,7 @@ export {
|
|
|
30
33
|
withoutRepeats,
|
|
31
34
|
} from './lanes'
|
|
32
35
|
export * from './model-column'
|
|
36
|
+
export * from './model-table'
|
|
33
37
|
export * from './phase-tally'
|
|
34
38
|
export * from './phases'
|
|
35
39
|
export * from './plan-structure'
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -29,6 +29,17 @@ const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
|
|
|
29
29
|
/** Sprint tables name a phase `P4`; the sidebar title prints it the same way. */
|
|
30
30
|
export const PHASE_ID_PREFIX = 'P'
|
|
31
31
|
|
|
32
|
+
const LEADING_PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?(\\d+[a-z]?)\\b`, 'i')
|
|
33
|
+
|
|
34
|
+
/** The phase a table's first cell names (`1 — Title`, `P1`, `10c`), lower-cased; null when it names none. */
|
|
35
|
+
export const phaseIdOf = (cell: string): string | null => LEADING_PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
36
|
+
|
|
37
|
+
/** What a report says when the plan has no dependency table. */
|
|
38
|
+
export const NO_DEPENDENCY_TABLE = 'no dependency table: phases run one after another'
|
|
39
|
+
|
|
40
|
+
/** `1 phase`, `3 phases`. */
|
|
41
|
+
export const phaseCount = (count: number): string => `${count} ${count === 1 ? 'phase' : 'phases'}`
|
|
42
|
+
|
|
32
43
|
/** A phase as plans and reports name it: `P4`. */
|
|
33
44
|
export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
|
|
34
45
|
|
|
@@ -56,13 +56,14 @@ export const firstHeading = (markdown: string, fallback: string): string =>
|
|
|
56
56
|
|
|
57
57
|
const HEADING = /^(#{2,4})\s+(.*?)\s*$/
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
/** The 0-based line index of the first level 2 to 4 heading whose text starts with `headingText` (a suffix such as `(PFLICHT ab 3 Phasen)` is allowed); -1 when there is none. */
|
|
60
|
+
export const subsectionStart = (markdown: string, headingText: string): number =>
|
|
61
|
+
markdown.split('\n').findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
|
|
62
|
+
|
|
63
|
+
/** The lines under that heading (see `subsectionStart`), up to the next heading of the same or a higher level. */
|
|
63
64
|
export const subsectionLines = (markdown: string, headingText: string): string[] => {
|
|
64
65
|
const lines = markdown.split('\n')
|
|
65
|
-
const start =
|
|
66
|
+
const start = subsectionStart(markdown, headingText)
|
|
66
67
|
if (start === -1) return []
|
|
67
68
|
const level = HEADING.exec(lines[start])?.[1].length ?? 2
|
|
68
69
|
const rest = lines.slice(start + 1)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { phaseIdOf } from './lanes'
|
|
3
|
+
import { firstTableRows, subsectionLines, subsectionStart } from './markdown'
|
|
4
|
+
import { type ColumnModel, columnModels } from './model-column'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A row of the model table: the phase it names, the models its model cell names (none for a dash), the model cell as
|
|
8
|
+
* written, and the row's 1-based line.
|
|
9
|
+
*/
|
|
10
|
+
export type ModelRow = { id: string; models: ColumnModel[]; cell: string; line: number }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The rows of the plan's model table (`### Model-Empfehlungen pro Phase`): the phase from the first cell (`1 — Title`,
|
|
14
|
+
* `P1`, `10c`), the models from the second, read like a board's model column. A row whose first cell names no phase is
|
|
15
|
+
* skipped; null when the plan has no such section or the section holds no table.
|
|
16
|
+
*/
|
|
17
|
+
export const parseModelTable = (markdown: string, language: PlanLanguage): ModelRow[] | null => {
|
|
18
|
+
const heading = PLAN_VOCABULARY[language].sections.modelRecommendations
|
|
19
|
+
const rows = firstTableRows(subsectionLines(markdown, heading))
|
|
20
|
+
if (rows.length === 0) return null
|
|
21
|
+
const firstBodyLine = subsectionStart(markdown, heading) + 2
|
|
22
|
+
return rows.flatMap(({ index, cells }) => {
|
|
23
|
+
const id = phaseIdOf(cells[0] ?? '')
|
|
24
|
+
const cell = cells[1] ?? ''
|
|
25
|
+
return id === null ? [] : [{ id, models: columnModels(cell), cell, line: firstBodyLine + index }]
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from '../context/log-vocabulary'
|
|
2
|
+
import { keptLogEntry } from '../context/session-log'
|
|
3
|
+
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { type RunningSubagent, type SubagentActivity, type SubagentLog, subagentLabelOf } from './subagents'
|
|
6
|
+
import { laneDirectoryOf } from './unlisted-lanes'
|
|
7
|
+
|
|
8
|
+
/** What a running subagent did last, with the name the herd calls it by. */
|
|
9
|
+
export type SubagentLine = { label: string; description: string | null; line: string }
|
|
10
|
+
|
|
11
|
+
/** What a worktree shows of its subagents, and the last action of each. */
|
|
12
|
+
export type LaneSubagents = SubagentActivity & { lines?: readonly SubagentLine[] }
|
|
13
|
+
|
|
14
|
+
/** The end of a log that is looked at: the last action is among the last entries, however long the log is. */
|
|
15
|
+
const SCANNED_ENTRIES = 60
|
|
16
|
+
const LINE_LENGTH = 200
|
|
17
|
+
const PATH_SEGMENTS = 2
|
|
18
|
+
/** The input fields that name what a tool works on, the first one present wins. */
|
|
19
|
+
const TARGET_FIELDS = ['file_path', 'command', 'pattern', 'url', 'query', 'description', 'path']
|
|
20
|
+
|
|
21
|
+
const singleLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
|
|
22
|
+
|
|
23
|
+
const shortPath = (path: string): string =>
|
|
24
|
+
path.includes('/') ? path.split('/').slice(-PATH_SEGMENTS).join('/') : path
|
|
25
|
+
|
|
26
|
+
const targetOf = (input: unknown): string => {
|
|
27
|
+
if (!isRecord(input)) return ''
|
|
28
|
+
const field = TARGET_FIELDS.find((name) => typeof input[name] === 'string' && input[name] !== '')
|
|
29
|
+
if (field === undefined) return ''
|
|
30
|
+
const value = String(input[field]).split('\n')[0]
|
|
31
|
+
return singleLine(field === 'file_path' || field === 'path' ? shortPath(value) : value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The line of one content block, null for a block that is no action: reasoning, whether it carries text or not. */
|
|
35
|
+
const lineOfBlock = (block: unknown): string | null => {
|
|
36
|
+
if (!isRecord(block)) return null
|
|
37
|
+
if (block.type === CONTENT_BLOCK_TYPE.toolUse && typeof block.name === 'string')
|
|
38
|
+
return singleLine(`${block.name} ${targetOf(block.input)}`)
|
|
39
|
+
if (block.type !== CONTENT_BLOCK_TYPE.text || typeof block.text !== 'string') return null
|
|
40
|
+
const first = block.text.split('\n').find((line) => line.trim() !== '')
|
|
41
|
+
return first === undefined ? null : singleLine(first)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lineOfEntry = (line: string): string | null => {
|
|
45
|
+
const entry = keptLogEntry(line)
|
|
46
|
+
if (entry?.type !== LOG_ENTRY_TYPE.assistant || !isRecord(entry.message) || !Array.isArray(entry.message.content))
|
|
47
|
+
return null
|
|
48
|
+
const blocks: unknown[] = entry.message.content
|
|
49
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
50
|
+
const found = lineOfBlock(blocks[index])
|
|
51
|
+
if (found) return found
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const scanned = new WeakMap<readonly string[], string | null>()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The last action in a log's lines: the newest assistant text line or tool call, a tool as its name and its short
|
|
60
|
+
* target (`Read plans/layout.ts`). Reasoning is left out, as most of it is empty in the log. Cut to a length a line of
|
|
61
|
+
* the herd can take; null when the end of the log holds none. A log that did not change (`followTailLines` hands back
|
|
62
|
+
* the very same array) is not scanned again.
|
|
63
|
+
*/
|
|
64
|
+
export const activityLineOf = (lines: readonly string[]): string | null => {
|
|
65
|
+
if (scanned.has(lines)) return scanned.get(lines) ?? null
|
|
66
|
+
let found: string | null = null
|
|
67
|
+
for (let index = lines.length - 1; index >= Math.max(lines.length - SCANNED_ENTRIES, 0) && found === null; index--)
|
|
68
|
+
found = lineOfEntry(lines[index])
|
|
69
|
+
const line = found === null ? null : ellipsize(found, LINE_LENGTH)
|
|
70
|
+
scanned.set(lines, line)
|
|
71
|
+
return line
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const labelOfLog = ({ id, meta }: SubagentLog): string | null =>
|
|
75
|
+
subagentLabelOf(id, isRecord(meta) && typeof meta.agentType === 'string' ? meta.agentType : null)
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `lanes` with the last action of each running subagent added to the lane it works in; a subagent whose log holds no
|
|
79
|
+
* action, or that works in no known lane, adds none. `lanes` itself stays as it was.
|
|
80
|
+
*/
|
|
81
|
+
export const withSubagentLines = (
|
|
82
|
+
lanes: ReadonlyMap<string, SubagentActivity>,
|
|
83
|
+
running: readonly RunningSubagent[],
|
|
84
|
+
logs: readonly SubagentLog[],
|
|
85
|
+
worktreeDirectory: string,
|
|
86
|
+
): Map<string, LaneSubagents> => {
|
|
87
|
+
const result = new Map<string, LaneSubagents>(lanes)
|
|
88
|
+
const tails = new Map(logs.flatMap((log) => [[labelOfLog(log), log.tail] as const]))
|
|
89
|
+
for (const { label, cwd, description } of running) {
|
|
90
|
+
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
91
|
+
const tail = tails.get(label)
|
|
92
|
+
const known = lane === null ? undefined : result.get(lane)
|
|
93
|
+
const line = tail === undefined ? null : activityLineOf(tail)
|
|
94
|
+
if (lane === null || known === undefined || line === null) continue
|
|
95
|
+
result.set(lane, { ...known, lines: [...(known.lines ?? []), { label, description, line }] })
|
|
96
|
+
}
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
2
|
import { resolveCoordinatorName } from '../context/coordinator-name'
|
|
3
|
-
import {
|
|
4
|
-
import { type
|
|
3
|
+
import { laneSenderForAsync } from '../settled/send'
|
|
4
|
+
import { type HerdrAsyncRunner, parseAgentList } from '../terminal/herdr'
|
|
5
5
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
6
6
|
import { ASK_KIND, type AskTarget, boardRowKey } from './ask-target'
|
|
7
7
|
import { errorText } from './error-text'
|
|
8
8
|
import { isQuestionId } from './question-records'
|
|
9
9
|
import { TEXTS } from './texts'
|
|
10
10
|
|
|
11
|
+
/** The footer line while the coordinator is being asked. */
|
|
12
|
+
export const ASKING_NOTICE = 'asking the coordinator …'
|
|
13
|
+
|
|
11
14
|
/** What `askCoordinator` needs from the outside: the dialog language, the terminal, the repository and herdr. */
|
|
12
15
|
export type AskDependencies = {
|
|
13
16
|
language: PlanLanguage
|
|
14
17
|
adapter: TerminalAdapter
|
|
15
18
|
/** The repository's directory name, from which the coordinator's herdr name follows. */
|
|
16
19
|
repository: string
|
|
17
|
-
run:
|
|
20
|
+
run: HerdrAsyncRunner
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
|
|
@@ -80,13 +83,18 @@ const subjectOf = (target: AskTarget): string => {
|
|
|
80
83
|
return `${SUBJECT_NAMES[target.kind]} ${subjectKeyOf(target)}${scope === '' ? '' : ` of ${scope}`}`
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
const agentNamesOf = (run:
|
|
86
|
+
const agentNamesOf = async (run: HerdrAsyncRunner): Promise<string[]> =>
|
|
87
|
+
parseAgentList(await run(['agent', 'list'])).map(({ name }) => name)
|
|
84
88
|
|
|
85
89
|
/** The coordinator's herdr name, or why there is none to reach; without a terminal the sender gives the reason. */
|
|
86
|
-
const coordinatorOf = ({
|
|
90
|
+
const coordinatorOf = async ({
|
|
91
|
+
adapter,
|
|
92
|
+
repository,
|
|
93
|
+
run,
|
|
94
|
+
}: AskDependencies): Promise<{ name: string } | { reason: string }> => {
|
|
87
95
|
if (adapter === TERMINAL_ADAPTER.none) return { name: resolveCoordinatorName(repository, []) }
|
|
88
96
|
try {
|
|
89
|
-
const names = agentNamesOf(run)
|
|
97
|
+
const names = await agentNamesOf(run)
|
|
90
98
|
const name = resolveCoordinatorName(repository, names)
|
|
91
99
|
return names.includes(name) ? { name } : { reason: `no agent named ${name}` }
|
|
92
100
|
} catch (error) {
|
|
@@ -96,14 +104,15 @@ const coordinatorOf = ({ adapter, repository, run }: AskDependencies): { name: s
|
|
|
96
104
|
|
|
97
105
|
/**
|
|
98
106
|
* 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.
|
|
100
|
-
* footer line
|
|
107
|
+
* coordinator that is working gets the line too: herdr queues it like a message in the middle of a turn. Resolves to
|
|
108
|
+
* the footer line, who was asked or why nobody was; the prompt waits up to `PROMPT_CONFIRM_TIMEOUT_MS`, so a live
|
|
109
|
+
* view shows `ASKING_NOTICE` meanwhile.
|
|
101
110
|
*/
|
|
102
|
-
export const askCoordinator = (target: AskTarget, dependencies: AskDependencies): string => {
|
|
111
|
+
export const askCoordinator = async (target: AskTarget, dependencies: AskDependencies): Promise<string> => {
|
|
103
112
|
if (!isAskable(target)) return TEXTS.notAsked('nothing to ask')
|
|
104
|
-
const coordinator = coordinatorOf(dependencies)
|
|
113
|
+
const coordinator = await coordinatorOf(dependencies)
|
|
105
114
|
if ('reason' in coordinator) return TEXTS.notAsked(coordinator.reason)
|
|
106
|
-
const failure =
|
|
115
|
+
const failure = await laneSenderForAsync(dependencies.adapter, dependencies.run)(
|
|
107
116
|
coordinator.name,
|
|
108
117
|
askLine(target, dependencies.language),
|
|
109
118
|
)
|