jackherd 1.8.0 → 1.9.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 +39 -0
- package/INSTALL.md +2 -0
- package/README.md +41 -60
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +22 -3
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- 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/merge-guard/decision.ts +70 -21
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -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 +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
- package/package.json +3 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/read-config.ts +5 -1
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +12 -6
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -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-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -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 +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +33 -1
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +5 -5
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- 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/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRecord } from '../shared/is-record'
|
|
2
|
+
import { cleanAgentName, cleanTaskText, MAX_TASK_TEXT } from './text'
|
|
3
|
+
import type { TaskSignal } from './types'
|
|
4
|
+
|
|
5
|
+
/** What `jh task start` stores in `.jackherd/tasks/<id>.json`: one record per agent, closed by `jh task done`. */
|
|
6
|
+
export type TaskRecord = {
|
|
7
|
+
id: string
|
|
8
|
+
agent: string
|
|
9
|
+
text: string
|
|
10
|
+
startedAt: number
|
|
11
|
+
cwd: string
|
|
12
|
+
closedAt: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ID = /^[0-9a-f]{12}$/
|
|
16
|
+
const ID_BYTES = 6
|
|
17
|
+
|
|
18
|
+
/** The id of a record from random bytes the caller drew, so a test reaches the drawing; 12 hex characters. */
|
|
19
|
+
export const buildTaskId = (randomBytes: Uint8Array): string =>
|
|
20
|
+
[...randomBytes.slice(0, ID_BYTES)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
21
|
+
|
|
22
|
+
export const isTaskRecord = (value: unknown): value is TaskRecord => {
|
|
23
|
+
if (!isRecord(value)) return false
|
|
24
|
+
const record = value
|
|
25
|
+
return (
|
|
26
|
+
typeof record.id === 'string' &&
|
|
27
|
+
ID.test(record.id) &&
|
|
28
|
+
typeof record.agent === 'string' &&
|
|
29
|
+
typeof record.text === 'string' &&
|
|
30
|
+
record.text.length <= MAX_TASK_TEXT &&
|
|
31
|
+
typeof record.startedAt === 'number' &&
|
|
32
|
+
typeof record.cwd === 'string' &&
|
|
33
|
+
(record.closedAt === null || typeof record.closedAt === 'number')
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fresh open record; the text and the agent name are cleaned here, whoever wrote them. */
|
|
38
|
+
export const openRecord = (
|
|
39
|
+
fields: { id: string; agent: string; text: string; cwd: string },
|
|
40
|
+
now: number,
|
|
41
|
+
): TaskRecord => ({
|
|
42
|
+
id: fields.id,
|
|
43
|
+
agent: cleanAgentName(fields.agent),
|
|
44
|
+
text: cleanTaskText(fields.text),
|
|
45
|
+
startedAt: now,
|
|
46
|
+
cwd: fields.cwd,
|
|
47
|
+
closedAt: null,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** Whether `text` names the record's task: empty text names any, else it is the start of the record's text. */
|
|
51
|
+
export const namesTask = (record: TaskRecord, text: string): boolean => {
|
|
52
|
+
const wanted = cleanTaskText(text)
|
|
53
|
+
return wanted === '' || record.text.startsWith(wanted)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The open records as signals; a record cleaned on write is cleaned again on read, as a hand-written file may differ. */
|
|
57
|
+
export const recordSignals = (records: readonly TaskRecord[]): TaskSignal[] =>
|
|
58
|
+
records
|
|
59
|
+
.filter(({ closedAt }) => closedAt === null)
|
|
60
|
+
.map(({ agent, text, startedAt, cwd }) => ({
|
|
61
|
+
agent: cleanAgentName(agent),
|
|
62
|
+
source: 'jh' as const,
|
|
63
|
+
text: cleanTaskText(text),
|
|
64
|
+
startedAt,
|
|
65
|
+
cwd,
|
|
66
|
+
}))
|
|
67
|
+
.filter(({ text }) => text !== '')
|
|
68
|
+
.sort((left, right) => left.startedAt - right.startedAt)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** The longest task text a signal carries; a plan task is matched on its first 200 characters as well. */
|
|
4
|
+
export const MAX_TASK_TEXT = 200
|
|
5
|
+
const MAX_AGENT_NAME = 100
|
|
6
|
+
|
|
7
|
+
const capped = (text: string, max: number): string => terminalSafe(text.replace(/\s+/g, ' ')).trim().slice(0, max)
|
|
8
|
+
|
|
9
|
+
/** Agent-written task text made safe to store and print: no control characters, one line, at most 200 characters. */
|
|
10
|
+
export const cleanTaskText = (text: string): string => capped(text, MAX_TASK_TEXT)
|
|
11
|
+
|
|
12
|
+
/** The same for the name of an agent. */
|
|
13
|
+
export const cleanAgentName = (name: string): string => capped(name, MAX_AGENT_NAME)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Who reported a task: Claude Code's task list, Codex's plan tool, or `jh task start`. */
|
|
2
|
+
export type TaskSource = 'claude' | 'codex' | 'jh'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One task an agent is working on right now. `agent` names the session or subagent that reported it, `startedAt` is
|
|
6
|
+
* epoch milliseconds, `cwd` the directory the agent worked in (its worktree tells the lane).
|
|
7
|
+
*/
|
|
8
|
+
export type TaskSignal = {
|
|
9
|
+
agent: string
|
|
10
|
+
source: TaskSource
|
|
11
|
+
text: string
|
|
12
|
+
startedAt: number
|
|
13
|
+
cwd: string
|
|
14
|
+
}
|
package/src/terminal/open.ts
CHANGED
|
@@ -5,10 +5,16 @@ export type OpenEnvironment = {
|
|
|
5
5
|
start: (command: string, args: string[]) => void
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
const OPENERS: Partial<Record<NodeJS.Platform, string>> = {
|
|
8
|
+
const OPENERS: Partial<Record<NodeJS.Platform, (target: string) => string[]>> = {
|
|
9
|
+
darwin: (target) => ['open', target],
|
|
10
|
+
linux: (target) => ['xdg-open', target],
|
|
11
|
+
/** Not `cmd /c start`: cmd reads `&`, `|`, `%` and `^` in the target as its own syntax; rundll32 takes it as one argument. */
|
|
12
|
+
win32: (target) => ['rundll32', 'url.dll,FileProtocolHandler', target],
|
|
13
|
+
}
|
|
9
14
|
|
|
10
|
-
/** The command that
|
|
11
|
-
export const
|
|
15
|
+
/** The command and arguments that open a file or a link in the default application of the platform, null when there is none. */
|
|
16
|
+
export const openCommandFor = (platform: NodeJS.Platform, target: string): string[] | null =>
|
|
17
|
+
OPENERS[platform]?.(target) ?? null
|
|
12
18
|
|
|
13
19
|
/** Linux opens things in a graphical session only; elsewhere the platform's opener is assumed to work. */
|
|
14
20
|
export const hasDisplay = ({ platform, env }: Pick<OpenEnvironment, 'platform' | 'env'>): boolean =>
|
|
@@ -27,11 +33,12 @@ const systemEnvironment = (): OpenEnvironment => ({
|
|
|
27
33
|
* opener for the platform, no display, the opener could not be started), null when it was started.
|
|
28
34
|
*/
|
|
29
35
|
export const openInBrowser = (target: string, environment: OpenEnvironment = systemEnvironment()): string | null => {
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
36
|
+
const command = openCommandFor(environment.platform, target)
|
|
37
|
+
if (!command) return `no way to open ${target} on ${environment.platform}`
|
|
32
38
|
if (!hasDisplay(environment)) return 'no display'
|
|
39
|
+
const [opener, ...args] = command
|
|
33
40
|
try {
|
|
34
|
-
environment.start(opener,
|
|
41
|
+
environment.start(opener, args)
|
|
35
42
|
return null
|
|
36
43
|
} catch (error) {
|
|
37
44
|
return `${opener} ${target}: ${error instanceof Error ? error.message : String(error)}`
|
package/src/terminal/text.ts
CHANGED
|
@@ -34,6 +34,13 @@ const FAINT_OFF = `${ESC}[22m`
|
|
|
34
34
|
const BAR_WIDTH = 10
|
|
35
35
|
|
|
36
36
|
const style = (code: string) => (text: string) => `${ESC}[${code}m${text}${RESET}`
|
|
37
|
+
const BRAND_TEAL_TRUE_COLOUR = '38;2;3;181;189'
|
|
38
|
+
const BRAND_TEAL_256 = '38;5;37'
|
|
39
|
+
|
|
40
|
+
/** The brand teal `#03B5BD` as true colour where `COLORTERM` says `truecolor` or `24bit`, else 256-colour 37. */
|
|
41
|
+
export const tealCodeOf = (env: Record<string, string | undefined>): string =>
|
|
42
|
+
env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' ? BRAND_TEAL_TRUE_COLOUR : BRAND_TEAL_256
|
|
43
|
+
|
|
37
44
|
export const paint = {
|
|
38
45
|
bold: style('1'),
|
|
39
46
|
dim: style('2'),
|
|
@@ -41,7 +48,7 @@ export const paint = {
|
|
|
41
48
|
green: style('32'),
|
|
42
49
|
yellow: style('33'),
|
|
43
50
|
magenta: style('35'),
|
|
44
|
-
teal: style(
|
|
51
|
+
teal: style(tealCodeOf(process.env)),
|
|
45
52
|
cyan: style('36'),
|
|
46
53
|
inverse: style('7'),
|
|
47
54
|
}
|
package/src/terminal/width.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const VARIATION_SELECTOR = 0xfe0f
|
|
2
|
+
const TEXT_SELECTOR = 0xfe0e
|
|
2
3
|
const ZERO_WIDTH_JOINER = 0x200d
|
|
3
4
|
const COMBINING_MARKS: readonly [number, number][] = [[0x0300, 0x036f]]
|
|
4
5
|
|
|
@@ -59,7 +60,7 @@ const within = (ranges: readonly [number, number][], codePoint: number): boolean
|
|
|
59
60
|
* variation selector (`⏸`) is one cell on its own; `textWidth` counts it as two when the selector follows.
|
|
60
61
|
*/
|
|
61
62
|
export const cellWidth = (codePoint: number): number => {
|
|
62
|
-
if (codePoint === VARIATION_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
|
|
63
|
+
if (codePoint === VARIATION_SELECTOR || codePoint === TEXT_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
|
|
63
64
|
if (within(COMBINING_MARKS, codePoint)) return 0
|
|
64
65
|
return within(WIDE, codePoint) ? 2 : 1
|
|
65
66
|
}
|
package/src/update/command.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { defaultUpdateCheck, lockedVersion } from '../sync/if-changed'
|
|
|
14
14
|
import { locateKit } from '../sync/kit-source'
|
|
15
15
|
import { readInstalledVersion } from '../version/installed-version'
|
|
16
16
|
import { CLI_NAME } from '../version/release'
|
|
17
|
+
import { rewriteConfigShares } from './config-shares'
|
|
17
18
|
import { detectPackageManager, legacyPackageRoot, shellHostedGlobalRoot } from './package-manager'
|
|
18
19
|
import { runUpdate, UPDATE_EXIT, type UpdateAdapters } from './update'
|
|
19
20
|
|
|
@@ -52,6 +53,7 @@ const adaptersFor = async (): Promise<UpdateAdapters> => {
|
|
|
52
53
|
home,
|
|
53
54
|
projectRoot: findProjectRoot(process.cwd(), home, existsSync),
|
|
54
55
|
hasProjectKit: (root) => hasProjectInstall(root, existsSync),
|
|
56
|
+
rewriteConfigShares,
|
|
55
57
|
log: (line) => console.log(line),
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { contextSharesTextInTokens } from '../config/context-shares'
|
|
4
|
+
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
|
+
|
|
6
|
+
type Converted = { text: string; notes: string[] }
|
|
7
|
+
|
|
8
|
+
/** The file's text with its shares in tokens; null when it is missing or not JSON, which reading the config names. */
|
|
9
|
+
const converted = (path: string): Converted | null => {
|
|
10
|
+
if (!existsSync(path)) return null
|
|
11
|
+
try {
|
|
12
|
+
return contextSharesTextInTokens(readFileSync(path, 'utf8'))
|
|
13
|
+
} catch {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Writes the context thresholds of `root`'s `.jackherd/config.json` as tokens when the file still gives them as a
|
|
20
|
+
* share, and returns a line for each block that changed. A project without an install of its own has no sync that
|
|
21
|
+
* would do it. A missing or unreadable file is left alone: reading the config names that error. A failed write
|
|
22
|
+
* throws, so `jh update` reports it.
|
|
23
|
+
*/
|
|
24
|
+
export const rewriteConfigShares = (root: string): string[] => {
|
|
25
|
+
const path = join(root, PROJECT_CONFIG_PATH)
|
|
26
|
+
const result = converted(path)
|
|
27
|
+
if (result === null || result.notes.length === 0) return []
|
|
28
|
+
writeFileSync(path, result.text)
|
|
29
|
+
return result.notes.map((note) => `${PROJECT_CONFIG_PATH}: ${note}`)
|
|
30
|
+
}
|
package/src/update/update.ts
CHANGED
|
@@ -28,6 +28,8 @@ export type UpdateAdapters = {
|
|
|
28
28
|
* The lock's existence counts, not its validity: a broken lock is synced, and the sync names the error.
|
|
29
29
|
*/
|
|
30
30
|
hasProjectKit: (root: string) => boolean
|
|
31
|
+
/** Writes the context thresholds in the project's config as tokens when they are still a share; returns a line for each change. */
|
|
32
|
+
rewriteConfigShares: (root: string) => string[]
|
|
31
33
|
log: (line: string) => void
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -46,6 +48,11 @@ const notInstalledLine = (projectRoot: string): string =>
|
|
|
46
48
|
const installedProject = (adapters: UpdateAdapters): string | null =>
|
|
47
49
|
adapters.projectRoot && adapters.hasProjectKit(adapters.projectRoot) ? adapters.projectRoot : null
|
|
48
50
|
|
|
51
|
+
/** Rewrites the context thresholds of a project without an install, which no sync does, and says what changed. */
|
|
52
|
+
const logConfigShares = (adapters: UpdateAdapters, root: string): void => {
|
|
53
|
+
for (const line of adapters.rewriteConfigShares(root)) adapters.log(line)
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
/** The syncs that bring `~/.claude` and the project to the new files, in the order they run. */
|
|
50
57
|
const syncSteps = async (
|
|
51
58
|
adapters: UpdateAdapters,
|
|
@@ -107,6 +114,7 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
|
|
|
107
114
|
const jhWasOnPath = adapters.jhOnPath()
|
|
108
115
|
if (jhWasOnPath && !isNewerVersion(latest, installedVersion)) {
|
|
109
116
|
log(`${CLI_NAME} ${installedVersion} is the latest release.`)
|
|
117
|
+
if (adapters.projectRoot && !installedProject(adapters)) logConfigShares(adapters, adapters.projectRoot)
|
|
110
118
|
return UPDATE_EXIT.ok
|
|
111
119
|
}
|
|
112
120
|
const installed = await installAndVerify(adapters, manager, latest)
|
|
@@ -115,7 +123,10 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
|
|
|
115
123
|
const synced = await syncAfterInstall(adapters, project)
|
|
116
124
|
if (synced !== UPDATE_EXIT.ok) return synced
|
|
117
125
|
log(doneLine(adapters, manager, latest, jhWasOnPath))
|
|
118
|
-
if (adapters.projectRoot && !project)
|
|
126
|
+
if (adapters.projectRoot && !project) {
|
|
127
|
+
logConfigShares(adapters, adapters.projectRoot)
|
|
128
|
+
log(notInstalledLine(adapters.projectRoot))
|
|
129
|
+
}
|
|
119
130
|
return UPDATE_EXIT.ok
|
|
120
131
|
}
|
|
121
132
|
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -66,7 +66,7 @@ export const APPROVAL_TEXTS: Record<PlanLanguage, { allow: string; deny: string;
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* The board section of agreed times, which the watcher reads: its heading, the header names of its columns (when,
|
|
69
|
-
* action, origin, status
|
|
69
|
+
* action, origin, status, found by name in any order; the status column may also carry a name of `statusAlso`) and the lower-case words a status cell of a still open
|
|
70
70
|
* appointment starts with.
|
|
71
71
|
*/
|
|
72
72
|
export const APPOINTMENT_VOCABULARY: Record<
|
|
@@ -74,17 +74,49 @@ export const APPOINTMENT_VOCABULARY: Record<
|
|
|
74
74
|
{
|
|
75
75
|
heading: string
|
|
76
76
|
columns: { when: string; action: string; origin: string; status: string }
|
|
77
|
+
/** Further header names that count as the status column. */
|
|
78
|
+
statusAlso: readonly string[]
|
|
77
79
|
open: readonly string[]
|
|
78
80
|
}
|
|
79
81
|
> = {
|
|
80
82
|
[PLAN_LANGUAGE.de]: {
|
|
81
83
|
heading: 'Termine',
|
|
82
84
|
columns: { when: 'Wann', action: 'Aktion', origin: 'Herkunft', status: 'Stand' },
|
|
85
|
+
statusAlso: ['Status'],
|
|
83
86
|
open: ['offen'],
|
|
84
87
|
},
|
|
85
88
|
[PLAN_LANGUAGE.en]: {
|
|
86
89
|
heading: 'Appointments',
|
|
87
90
|
columns: { when: 'When', action: 'Action', origin: 'Origin', status: 'Status' },
|
|
91
|
+
statusAlso: [],
|
|
92
|
+
open: ['open'],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The board section of what waits on the user's answer (a sent preview, an open question, a report to accept): its
|
|
98
|
+
* heading, the header names of its columns (since, item, origin, status, found by name in any order; the status
|
|
99
|
+
* column may also carry a name of `statusAlso`) and the lower-case words a status cell of a still open item starts with.
|
|
100
|
+
*/
|
|
101
|
+
export const WAITING_VOCABULARY: Record<
|
|
102
|
+
PlanLanguage,
|
|
103
|
+
{
|
|
104
|
+
heading: string
|
|
105
|
+
columns: { since: string; item: string; origin: string; status: string }
|
|
106
|
+
statusAlso: readonly string[]
|
|
107
|
+
open: readonly string[]
|
|
108
|
+
}
|
|
109
|
+
> = {
|
|
110
|
+
[PLAN_LANGUAGE.de]: {
|
|
111
|
+
heading: 'Wartet auf dich',
|
|
112
|
+
columns: { since: 'Seit', item: 'Punkt', origin: 'Herkunft', status: 'Stand' },
|
|
113
|
+
statusAlso: ['Status'],
|
|
114
|
+
open: ['offen'],
|
|
115
|
+
},
|
|
116
|
+
[PLAN_LANGUAGE.en]: {
|
|
117
|
+
heading: 'Waiting for you',
|
|
118
|
+
columns: { since: 'Since', item: 'Item', origin: 'Origin', status: 'Status' },
|
|
119
|
+
statusAlso: [],
|
|
88
120
|
open: ['open'],
|
|
89
121
|
},
|
|
90
122
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import type { WaitingItem } from '../plan-format/waiting'
|
|
3
|
+
import { mainCheckout } from '../plans/git'
|
|
4
|
+
import { sentWaitingItems } from '../plans/sent-files'
|
|
5
|
+
import { closeAllSentRecords, closeSentRecords, readSentRecords } from '../plans/sent-files-read'
|
|
6
|
+
import { CLI_NAME } from '../version/release'
|
|
7
|
+
|
|
8
|
+
const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
|
|
9
|
+
|
|
10
|
+
/** The lines `jh waiting` prints: id, time and text of each item, made safe for the terminal; a note when none waits. */
|
|
11
|
+
export const waitingListing = (items: readonly WaitingItem[]): string[] =>
|
|
12
|
+
items.length === 0
|
|
13
|
+
? ['nothing waits on you']
|
|
14
|
+
: items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`))
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `jh waiting` lists the files sent to the user that no one closed (their id, time and caption);
|
|
18
|
+
* `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong command line.
|
|
19
|
+
*/
|
|
20
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
21
|
+
const root = mainCheckout(process.cwd())
|
|
22
|
+
if (args.length === 0) {
|
|
23
|
+
for (const line of waitingListing(sentWaitingItems(readSentRecords(root), Date.now()))) console.log(line)
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
const [command, ...rest] = args
|
|
27
|
+
if (command !== 'close' || rest.length === 0) {
|
|
28
|
+
console.error(USAGE)
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
const closed = rest.includes('--all') ? closeAllSentRecords(root, now) : closeSentRecords(root, rest, now)
|
|
33
|
+
console.log(`closed ${closed}`)
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
1
2
|
import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
2
4
|
import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
-
import { isRecord } from './json-guards'
|
|
4
5
|
|
|
5
6
|
export const APPOINTMENT_EVENT = { due: 'due', unreadable: 'appointment unreadable' } as const
|
|
6
7
|
|
|
@@ -90,8 +91,6 @@ export const parseAppointmentTime = (text: string, now: Date): Date | null => {
|
|
|
90
91
|
return parts.year === null ? nearestToClock(parts, now) : localTime(parts, parts.year)
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
94
|
-
|
|
95
94
|
/** The local time to the minute and the action: a moved appointment gets a new key, a reworded day name does not. */
|
|
96
95
|
export const appointmentKey = (when: Date, action: string): string =>
|
|
97
96
|
`${when.getFullYear()}-${twoDigits(when.getMonth() + 1)}-${twoDigits(when.getDate())}T${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())} ${action}`
|
|
@@ -132,10 +131,11 @@ const tableOfRow = (
|
|
|
132
131
|
* listed as unreadable as a whole, with its header and the missing names; a section without a table has no rows.
|
|
133
132
|
*/
|
|
134
133
|
export const readAppointmentTable = (board: string, language: PlanLanguage, now: Date): AppointmentTable => {
|
|
135
|
-
const { heading, columns } = APPOINTMENT_VOCABULARY[language]
|
|
134
|
+
const { heading, columns, statusAlso } = APPOINTMENT_VOCABULARY[language]
|
|
136
135
|
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
137
136
|
if (header.length === 0) return { appointments: [], unreadable: [] }
|
|
138
|
-
const
|
|
137
|
+
const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
|
|
138
|
+
const needed = [columns.when, columns.action, statusName]
|
|
139
139
|
const missing = needed.filter((name) => !header.includes(name))
|
|
140
140
|
if (missing.length > 0)
|
|
141
141
|
return {
|
package/src/watch/approvals.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
4
5
|
|
|
5
6
|
export const APPROVAL_EVENT = 'approval'
|
|
6
7
|
|
|
@@ -49,7 +50,6 @@ export const isApprovalRequestFile = (value: unknown): value is ApprovalRequestF
|
|
|
49
50
|
(value.usedAt === null || typeof value.usedAt === 'number')
|
|
50
51
|
|
|
51
52
|
const REPORT_MINUTES = 30
|
|
52
|
-
const MS_PER_MINUTE = 60_000
|
|
53
53
|
|
|
54
54
|
/** One event per open request younger than 30 minutes the watcher has not reported yet; an older one is stale. */
|
|
55
55
|
export const approvalEvents = (
|
package/src/watch/command.ts
CHANGED
|
@@ -37,11 +37,14 @@ import { contextReport } from '../context/report'
|
|
|
37
37
|
import { agentLog } from '../context/session-log'
|
|
38
38
|
import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
|
|
39
39
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
40
|
+
import { openWaitingItems } from '../plan-format/waiting'
|
|
40
41
|
import { boardFor, foundLanesOf } from '../plans/board-pick'
|
|
41
42
|
import { ensurePlanPanes } from '../plans/ensure-panes'
|
|
42
43
|
import { errorText } from '../plans/error-text'
|
|
43
44
|
import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
|
|
44
45
|
import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
|
|
46
|
+
import { sentWaitingItems } from '../plans/sent-files'
|
|
47
|
+
import { readSentRecords } from '../plans/sent-files-read'
|
|
45
48
|
import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
|
|
46
49
|
import { boardTexts } from '../plans/texts'
|
|
47
50
|
import { defaultUpdateCheck } from '../sync/if-changed'
|
|
@@ -107,6 +110,7 @@ import {
|
|
|
107
110
|
watchEventLine,
|
|
108
111
|
} from './lane-report'
|
|
109
112
|
import { laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
|
|
113
|
+
import { restNote } from './lane-rest'
|
|
110
114
|
import { branchOf, fetchBaseWhenMoved } from './lane-sources'
|
|
111
115
|
import {
|
|
112
116
|
advanceBaseline,
|
|
@@ -148,8 +152,17 @@ import { runSource, SOURCE_LIMITS } from './source-run'
|
|
|
148
152
|
import { recordWatchedSources } from './source-tickets'
|
|
149
153
|
import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
|
|
150
154
|
import { streamHeartbeatLine } from './stream-output'
|
|
155
|
+
import {
|
|
156
|
+
formatTaskParallelLine,
|
|
157
|
+
TASK_PARALLEL_EVENT,
|
|
158
|
+
type TaskParallelEvent,
|
|
159
|
+
type TaskParallelFingerprints,
|
|
160
|
+
taskParallelReport,
|
|
161
|
+
} from './task-parallel'
|
|
162
|
+
import { readTaskParallelInputs } from './task-parallel-read'
|
|
151
163
|
import { type WatchTexts, watchTexts } from './texts'
|
|
152
164
|
import { updateAnnouncer } from './update-available'
|
|
165
|
+
import { waitingLine } from './waiting-line'
|
|
153
166
|
import {
|
|
154
167
|
coordinatorWatchEvent,
|
|
155
168
|
deployEvents,
|
|
@@ -184,6 +197,7 @@ type Files = { state: string; lock: string }
|
|
|
184
197
|
type FeedbackConfig = JackherdConfig['watch']['feedback']
|
|
185
198
|
|
|
186
199
|
type ChecksConfig = JackherdConfig['watch']['checks']
|
|
200
|
+
type ParallelTasksConfig = JackherdConfig['watch']['parallelTasks']
|
|
187
201
|
|
|
188
202
|
/** Where deploy previews are read from; undefined when the project reads none. */
|
|
189
203
|
type PreviewsConfig = JackherdConfig['watch']['previews']
|
|
@@ -203,6 +217,7 @@ type Watch = {
|
|
|
203
217
|
deploy: DeployConfig | undefined
|
|
204
218
|
feedback: FeedbackConfig
|
|
205
219
|
checks: ChecksConfig
|
|
220
|
+
parallelTasks: ParallelTasksConfig
|
|
206
221
|
previews: PreviewsConfig
|
|
207
222
|
sources: SourceSettings[]
|
|
208
223
|
texts: WatchTexts
|
|
@@ -260,6 +275,8 @@ type Watching = {
|
|
|
260
275
|
laneModelReadings: Record<string, LaneModelReading>
|
|
261
276
|
/** The phases of each lane's plan that can run together at the last read; recorded with every state like the models. */
|
|
262
277
|
laneParallelReadings: Record<string, string[]>
|
|
278
|
+
/** The parallel-task fingerprint of each lane at the last heartbeat; persisted like the lane parallels. */
|
|
279
|
+
taskParallels: TaskParallelFingerprints
|
|
263
280
|
/** The approval requests of the last poll; null before the first read, so the stored ids stay. */
|
|
264
281
|
approvalRequests: ApprovalRequestFile[] | null
|
|
265
282
|
/** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
|
|
@@ -299,6 +316,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
299
316
|
laneLevels: recordLaneLevels(withReads.laneLevels, watching.laneReadings, watching.lanes),
|
|
300
317
|
laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
|
|
301
318
|
laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
|
|
319
|
+
taskParallels: watching.taskParallels,
|
|
302
320
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
303
321
|
sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
|
|
304
322
|
appointments: watching.appointments
|
|
@@ -340,6 +358,12 @@ const screenOf = (lane: string): string => {
|
|
|
340
358
|
const reportPathOf = (watch: Watch, lane: string): string | null =>
|
|
341
359
|
laneReportPath(worktreeOf(watch.root, watch.settings, lane), lane)
|
|
342
360
|
|
|
361
|
+
/** What a resting lane waits on, from its report file (`restNote`); null while it works or when it asks nothing. */
|
|
362
|
+
const restNoteOf = (watch: Watch, lane: string, status: string | undefined): string | null =>
|
|
363
|
+
status !== undefined && IDLE_STATUSES.has(status)
|
|
364
|
+
? restNote(readLaneReport(reportPathOf(watch, lane))?.text ?? null)
|
|
365
|
+
: null
|
|
366
|
+
|
|
343
367
|
/** The lines under a stopped lane's header and its report time to remember (`laneMessage`). */
|
|
344
368
|
const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchState) =>
|
|
345
369
|
laneMessage({
|
|
@@ -451,6 +475,7 @@ const boardReadOf = async (
|
|
|
451
475
|
|
|
452
476
|
/** What the status line of one lane is made of, from the loop's state. */
|
|
453
477
|
const laneReportOf = (
|
|
478
|
+
watch: Watch,
|
|
454
479
|
lane: string,
|
|
455
480
|
watching: Watching,
|
|
456
481
|
state: WatchState,
|
|
@@ -467,6 +492,7 @@ const laneReportOf = (
|
|
|
467
492
|
pullRequest: pullRequest ? { number: pullRequest.number, checks: pullRequest.checks, feedback } : null,
|
|
468
493
|
progress,
|
|
469
494
|
context: watching.laneReadings[lane]?.level ?? null,
|
|
495
|
+
rest: restNoteOf(watch, lane, watching.current[lane]?.status),
|
|
470
496
|
}
|
|
471
497
|
}
|
|
472
498
|
|
|
@@ -500,16 +526,17 @@ const statusLinesOf = async (
|
|
|
500
526
|
const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
|
|
501
527
|
const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes)
|
|
502
528
|
const laneLines = watching.lanes.map((lane) =>
|
|
503
|
-
laneReportLine(laneReportOf(lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
|
|
529
|
+
laneReportLine(laneReportOf(watch, lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
|
|
504
530
|
)
|
|
505
531
|
const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(state.ghDownSince))]
|
|
532
|
+
const waiting = waitingLineOf(watch)
|
|
506
533
|
const board = { term: watch.settings.board.term, checkoutRows }
|
|
507
534
|
const coordinator = coordinatorStatusLine(watching.coordinator.name, watching.coordinator.read, watch.texts, board)
|
|
508
535
|
return {
|
|
509
536
|
laneLines,
|
|
510
537
|
coordinator,
|
|
511
538
|
others: others === null ? [] : [others],
|
|
512
|
-
rest: [...notes, ...outage, ...failingSourceLines(state.sources)],
|
|
539
|
+
rest: [...notes, ...(waiting === null ? [] : [waiting]), ...outage, ...failingSourceLines(state.sources)],
|
|
513
540
|
}
|
|
514
541
|
}
|
|
515
542
|
|
|
@@ -813,6 +840,13 @@ const readAppointments = (watch: Watch, now: number): Watching['appointments'] =
|
|
|
813
840
|
return { table: readAppointmentTable(board, watch.settings.planLanguage, new Date(now)), readAt: now }
|
|
814
841
|
}
|
|
815
842
|
|
|
843
|
+
/** The line of what waits on the user's answer, from the board's own section; null when nothing is open or the board cannot be read. */
|
|
844
|
+
const waitingLineOf = (watch: Watch): string | null => {
|
|
845
|
+
const board = readBoardText(watch)
|
|
846
|
+
const sends = sentWaitingItems(readSentRecords(watch.root), Date.now())
|
|
847
|
+
return waitingLine([...(board === null ? [] : openWaitingItems(board, watch.settings.planLanguage)), ...sends])
|
|
848
|
+
}
|
|
849
|
+
|
|
816
850
|
/** The board's lanes, null when the board cannot be read. */
|
|
817
851
|
const readSprintLanes = (watch: Watch): SprintLane[] | null => {
|
|
818
852
|
const board = readBoardText(watch)
|
|
@@ -896,6 +930,39 @@ const report = async (
|
|
|
896
930
|
return shown
|
|
897
931
|
}
|
|
898
932
|
|
|
933
|
+
/**
|
|
934
|
+
* The parallel-task proposals of this heartbeat, one event per open lane whose situation changed since the last
|
|
935
|
+
* heartbeat and that has a proposal, and the fingerprints of every lane read, kept for the next one. Nothing when
|
|
936
|
+
* herdr or the board cannot be read: a proposal is a hint, and the next heartbeat reads again.
|
|
937
|
+
*/
|
|
938
|
+
const taskParallelEventsOf = (
|
|
939
|
+
watch: Watch,
|
|
940
|
+
watching: Watching,
|
|
941
|
+
now: number,
|
|
942
|
+
): { events: TaskParallelEvent[]; fingerprints: TaskParallelFingerprints } => {
|
|
943
|
+
try {
|
|
944
|
+
const lanes = (readSprintLanes(watch) ?? []).filter((lane) => !lane.closed && watching.lanes.includes(lane.name))
|
|
945
|
+
const inputs = readTaskParallelInputs(lanes, herdrLaneStatusSource.listAgents(), {
|
|
946
|
+
root: watch.root,
|
|
947
|
+
settings: watch.settings,
|
|
948
|
+
boardFile: watch.sprintFile,
|
|
949
|
+
thresholdTokens: watch.parallelTasks.maxTokens,
|
|
950
|
+
now,
|
|
951
|
+
})
|
|
952
|
+
const reports = Object.entries(inputs).map(
|
|
953
|
+
([lane, input]) => [lane, taskParallelReport(watching.taskParallels[lane], input)] as const,
|
|
954
|
+
)
|
|
955
|
+
return {
|
|
956
|
+
events: reports.flatMap(([lane, { proposal }]) =>
|
|
957
|
+
proposal ? [{ kind: TASK_PARALLEL_EVENT, lane, line: formatTaskParallelLine(lane, proposal) }] : [],
|
|
958
|
+
),
|
|
959
|
+
fingerprints: Object.fromEntries(reports.map(([lane, { fingerprint }]) => [lane, fingerprint])),
|
|
960
|
+
}
|
|
961
|
+
} catch {
|
|
962
|
+
return { events: [], fingerprints: {} }
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
899
966
|
/**
|
|
900
967
|
* The heartbeat of a stream: one line, a working lane with its phase count when that moved since the last heartbeat.
|
|
901
968
|
* The plans are read only when a lane works; a board that cannot be read leaves the lines without a count, and the
|
|
@@ -907,7 +974,16 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
|
|
|
907
974
|
const counts = currentPhaseCountsOf(progress)
|
|
908
975
|
const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
|
|
909
976
|
watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
|
|
910
|
-
|
|
977
|
+
const rests = Object.fromEntries(
|
|
978
|
+
watching.lanes.flatMap((lane) => {
|
|
979
|
+
const rest = restNoteOf(watch, lane, watching.current[lane]?.status)
|
|
980
|
+
return rest === null ? [] : [[lane, rest]]
|
|
981
|
+
}),
|
|
982
|
+
)
|
|
983
|
+
console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, waitingLineOf(watch)))
|
|
984
|
+
const { events, fingerprints } = taskParallelEventsOf(watch, watching, now)
|
|
985
|
+
watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
|
|
986
|
+
for (const event of events) console.log(watchEventLine(event, null))
|
|
911
987
|
}
|
|
912
988
|
|
|
913
989
|
/**
|
|
@@ -941,9 +1017,17 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
|
|
|
941
1017
|
next = await report(watch, watching, stopped, polledEvents, next)
|
|
942
1018
|
watching.baseline = { ...watching.current }
|
|
943
1019
|
} else if (heartbeat && !stream) {
|
|
944
|
-
|
|
1020
|
+
const { events: proposals, fingerprints } = taskParallelEventsOf(watch, watching, now)
|
|
1021
|
+
watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
|
|
1022
|
+
next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }, ...proposals], {
|
|
1023
|
+
...recordedOf(watch, watching),
|
|
1024
|
+
statusLines: null,
|
|
1025
|
+
})
|
|
945
1026
|
} else {
|
|
946
|
-
if (heartbeat)
|
|
1027
|
+
if (heartbeat) {
|
|
1028
|
+
await printStreamHeartbeat(watch, watching, now)
|
|
1029
|
+
next = recordedOf(watch, watching)
|
|
1030
|
+
}
|
|
947
1031
|
if (due) writePrivate(watch.files.state, JSON.stringify(next))
|
|
948
1032
|
watching.baseline = advanceBaseline(watching.baseline, watching.current)
|
|
949
1033
|
}
|
|
@@ -983,6 +1067,7 @@ const initialWatching = (
|
|
|
983
1067
|
laneReadings: {},
|
|
984
1068
|
laneModelReadings: {},
|
|
985
1069
|
laneParallelReadings: {},
|
|
1070
|
+
taskParallels: start.state.taskParallels,
|
|
986
1071
|
approvalRequests: null,
|
|
987
1072
|
appointments: null,
|
|
988
1073
|
planViews: { failureReported: false },
|
|
@@ -1048,6 +1133,7 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1048
1133
|
deploy: config.watch.deploy,
|
|
1049
1134
|
feedback: config.watch.feedback,
|
|
1050
1135
|
checks: config.watch.checks,
|
|
1136
|
+
parallelTasks: config.watch.parallelTasks,
|
|
1051
1137
|
previews: config.watch.previews,
|
|
1052
1138
|
sources: config.watch.sources,
|
|
1053
1139
|
texts: watchTexts(settings.responseLanguage),
|