jackherd 1.8.1 → 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 +30 -0
- package/INSTALL.md +2 -0
- package/README.md +37 -44
- package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +19 -2
- 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 +67 -20
- 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 +30 -6
- package/package.json +2 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- 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 +9 -4
- 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/vocabulary/index.ts +28 -0
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +2 -3
- 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
|
@@ -178,6 +178,17 @@
|
|
|
178
178
|
},
|
|
179
179
|
"additionalProperties": false
|
|
180
180
|
},
|
|
181
|
+
"preview": {
|
|
182
|
+
"default": {},
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {
|
|
185
|
+
"publish": {
|
|
186
|
+
"default": true,
|
|
187
|
+
"type": "boolean"
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"additionalProperties": false
|
|
191
|
+
},
|
|
181
192
|
"plans": {
|
|
182
193
|
"default": {},
|
|
183
194
|
"type": "object",
|
|
@@ -270,13 +281,13 @@
|
|
|
270
281
|
"type": "object",
|
|
271
282
|
"properties": {
|
|
272
283
|
"warnTokens": {
|
|
273
|
-
"default":
|
|
284
|
+
"default": 350000,
|
|
274
285
|
"type": "integer",
|
|
275
286
|
"exclusiveMinimum": 0,
|
|
276
287
|
"maximum": 9007199254740991
|
|
277
288
|
},
|
|
278
289
|
"alertTokens": {
|
|
279
|
-
"default":
|
|
290
|
+
"default": 500000,
|
|
280
291
|
"type": "integer",
|
|
281
292
|
"exclusiveMinimum": 0,
|
|
282
293
|
"maximum": 9007199254740991
|
|
@@ -361,6 +372,19 @@
|
|
|
361
372
|
},
|
|
362
373
|
"additionalProperties": false
|
|
363
374
|
},
|
|
375
|
+
"parallelTasks": {
|
|
376
|
+
"default": {},
|
|
377
|
+
"type": "object",
|
|
378
|
+
"properties": {
|
|
379
|
+
"maxTokens": {
|
|
380
|
+
"default": 100000,
|
|
381
|
+
"type": "integer",
|
|
382
|
+
"exclusiveMinimum": 0,
|
|
383
|
+
"maximum": 9007199254740991
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
"additionalProperties": false
|
|
387
|
+
},
|
|
364
388
|
"previews": {
|
|
365
389
|
"type": "object",
|
|
366
390
|
"properties": {
|
package/src/cli.ts
CHANGED
|
@@ -24,9 +24,12 @@ export const COMMANDS: CommandTable = {
|
|
|
24
24
|
[HERD_COMMAND.name]: () => import('./plans/command'),
|
|
25
25
|
init: () => import('./sync/init-command'),
|
|
26
26
|
[HERD_COMMAND.alias]: () => import('./plans/command'),
|
|
27
|
+
preview: () => import('./preview/command'),
|
|
27
28
|
settled: () => import('./settled/command'),
|
|
28
29
|
sync: () => import('./sync/command'),
|
|
30
|
+
task: () => import('./task-signals/command'),
|
|
29
31
|
update: () => import('./update/command'),
|
|
32
|
+
waiting: () => import('./waiting/command'),
|
|
30
33
|
watch: () => import('./watch/command'),
|
|
31
34
|
}
|
|
32
35
|
|
package/src/config/schema.ts
CHANGED
|
@@ -118,6 +118,17 @@ const feedbackSchema = z.strictObject({
|
|
|
118
118
|
noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
const DEFAULT_PARALLEL_TASKS_MAX_TOKENS = 100_000
|
|
122
|
+
|
|
123
|
+
const parallelTasksSchema = z.strictObject({
|
|
124
|
+
/**
|
|
125
|
+
* The most the extra agents of a parallel proposal may cost before they do anything: the token baseline of a fresh
|
|
126
|
+
* agent times the agents to add. Above it the watcher does not propose parallel tasks. The baseline is the context
|
|
127
|
+
* the first answer of a lane's session carried.
|
|
128
|
+
*/
|
|
129
|
+
maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
|
|
130
|
+
})
|
|
131
|
+
|
|
121
132
|
const checksSchema = z.strictObject({
|
|
122
133
|
/**
|
|
123
134
|
* Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
|
|
@@ -228,9 +239,9 @@ const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
|
|
|
228
239
|
const coordinatorSchema = z
|
|
229
240
|
.strictObject({
|
|
230
241
|
/** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
|
|
231
|
-
warnTokens: tokenCount.default(
|
|
242
|
+
warnTokens: tokenCount.default(350_000),
|
|
232
243
|
/** Tokens in the coordinator's context from which the watcher reports `coordinator alert`. */
|
|
233
|
-
alertTokens: tokenCount.default(
|
|
244
|
+
alertTokens: tokenCount.default(500_000),
|
|
234
245
|
})
|
|
235
246
|
.refine(warnBelowAlert, WARN_BELOW_ALERT)
|
|
236
247
|
|
|
@@ -410,6 +421,13 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
410
421
|
terminal: z
|
|
411
422
|
.strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
|
|
412
423
|
.prefault({}),
|
|
424
|
+
/** How the coordinator shows a draft for review (`orchestrate` § 4). */
|
|
425
|
+
preview: z
|
|
426
|
+
.strictObject({
|
|
427
|
+
/** Publish the draft as a private Artifact when the session has the tool; `false` sends a file card or `jh preview --open` instead. */
|
|
428
|
+
publish: z.boolean().default(true),
|
|
429
|
+
})
|
|
430
|
+
.prefault({}),
|
|
413
431
|
plans: z
|
|
414
432
|
.strictObject({
|
|
415
433
|
/** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
|
|
@@ -430,6 +448,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
430
448
|
/**
|
|
431
449
|
* Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
|
|
432
450
|
* it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
|
|
451
|
+
* `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
|
|
433
452
|
* `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
|
|
434
453
|
* Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
|
|
435
454
|
* command whose JSON output reports `source` for a new or changed entry.
|
|
@@ -439,6 +458,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
439
458
|
deploy: deploySchema.optional(),
|
|
440
459
|
feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
|
|
441
460
|
checks: checksSchema.prefault({}),
|
|
461
|
+
parallelTasks: parallelTasksSchema.prefault({}),
|
|
442
462
|
previews: previewsSchema.optional(),
|
|
443
463
|
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
444
464
|
})
|
package/src/context/fill.ts
CHANGED
|
@@ -67,3 +67,15 @@ export const contextFill = (lines: readonly string[], windows: ContextWindows):
|
|
|
67
67
|
const window = windowFor(last.model, highest, windows)
|
|
68
68
|
return { tokens: last.tokens, window, share: Math.min(last.tokens / window, 1), model: last.model }
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a fresh agent carries before it has worked: the input of the first main-chain answer in the lines, which holds
|
|
73
|
+
* the system prompt, the project instructions and the first request. Null when no line is a usable answer.
|
|
74
|
+
*/
|
|
75
|
+
export const firstAnswerTokens = (lines: readonly string[]): number | null => {
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
const answer = answerOf(line)
|
|
78
|
+
if (answer) return answer.tokens
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
@@ -2,6 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync, stat
|
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
|
|
5
|
+
import { isRecord } from '../shared/is-record'
|
|
5
6
|
|
|
6
7
|
/** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
|
|
7
8
|
export const TAIL_BYTES = 2 * 1024 * 1024
|
|
@@ -40,6 +41,20 @@ export const readTailLines = (path: string, bytes: number): string[] => {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** The complete lines of the first `bytes` of a file: a cut-off last line is dropped, a file read to its end keeps its last one. */
|
|
45
|
+
export const readHeadLines = (path: string, bytes: number): string[] => {
|
|
46
|
+
const descriptor = openSync(path, 'r')
|
|
47
|
+
try {
|
|
48
|
+
const { size } = fstatSync(descriptor)
|
|
49
|
+
const buffer = Buffer.alloc(Math.min(size, bytes))
|
|
50
|
+
readSync(descriptor, buffer, 0, buffer.length, 0)
|
|
51
|
+
const lines = buffer.toString('utf8').split('\n')
|
|
52
|
+
return size > bytes ? lines.slice(0, -1) : lines
|
|
53
|
+
} finally {
|
|
54
|
+
closeSync(descriptor)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
/** The tail of a session's log, read only; null when the session has no log or it cannot be read. */
|
|
44
59
|
export const sessionLog = (
|
|
45
60
|
sessionId: string,
|
|
@@ -59,7 +74,7 @@ export type AgentSession = { session: string | null; cwd: string | null }
|
|
|
59
74
|
const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes('"type":"assistant"'))
|
|
60
75
|
|
|
61
76
|
/** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
|
|
62
|
-
const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
|
|
77
|
+
export const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
|
|
63
78
|
|
|
64
79
|
const newestLogOf = (cwd: string, projectsDirectory: string): string | null => {
|
|
65
80
|
const directory = join(projectsDirectory, projectDirectoryOf(cwd))
|
|
@@ -89,3 +104,30 @@ export const agentLog = (
|
|
|
89
104
|
return reported
|
|
90
105
|
}
|
|
91
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The path of the log `agentLog` would read: the reported session's log when it holds an answer, else the newest log
|
|
110
|
+
* of the agent's working directory, else the reported one; null when there is none.
|
|
111
|
+
*/
|
|
112
|
+
export const agentLogPath = (
|
|
113
|
+
{ session, cwd }: AgentSession,
|
|
114
|
+
projectsDirectory: string = defaultProjectsDirectory(),
|
|
115
|
+
): string | null => {
|
|
116
|
+
const reported = session ? sessionLogPath(session, projectsDirectory) : null
|
|
117
|
+
try {
|
|
118
|
+
if (reported && hasAnswer(readTailLines(reported, TAIL_BYTES))) return reported
|
|
119
|
+
return (cwd ? newestLogOf(cwd, projectsDirectory) : null) ?? reported
|
|
120
|
+
} catch {
|
|
121
|
+
return reported
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line of a session log as an object; null for a line that is no JSON or not an object. */
|
|
126
|
+
export const parseLogEntry = (line: string): Record<string, unknown> | null => {
|
|
127
|
+
try {
|
|
128
|
+
const entry: unknown = JSON.parse(line)
|
|
129
|
+
return isRecord(entry) ? entry : null
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { PHASE_ID_PREFIX, phaseLabels } from './lanes'
|
|
2
|
+
import { PHASE_ID_PREFIX, phaseLabels, spannedPhaseIds } from './lanes'
|
|
3
3
|
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
5
|
import { isPhaseFinished } from './progress'
|
|
@@ -40,7 +40,6 @@ const VERSION = /^v?\d+(?:[.\-+][\w.-]*)?$/i
|
|
|
40
40
|
const PACKAGE_SCOPE = /^@[^/]+\//
|
|
41
41
|
/** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
|
|
42
42
|
const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
|
|
43
|
-
const MAX_RANGE = 50
|
|
44
43
|
|
|
45
44
|
const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
46
45
|
|
|
@@ -63,9 +62,7 @@ const splitOutsideParentheses = (cell: string, separator: string): string[] => {
|
|
|
63
62
|
const rangeOf = (entry: string): string[] | null => {
|
|
64
63
|
const match = PHASE_RANGE.exec(entry)
|
|
65
64
|
if (!match) return null
|
|
66
|
-
|
|
67
|
-
if (first > last || last - first >= MAX_RANGE) return []
|
|
68
|
-
return Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
|
|
65
|
+
return spannedPhaseIds(Number(match[1]), Number(match[2]))
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'after' | 'external'> => {
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -32,6 +32,15 @@ export const PHASE_ID_PREFIX = 'P'
|
|
|
32
32
|
/** A phase as plans and reports name it: `P4`. */
|
|
33
33
|
export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
|
|
34
34
|
|
|
35
|
+
/** The widest range a cell may span; a wider one is a typo, not a plan. */
|
|
36
|
+
const MAX_RANGE = 50
|
|
37
|
+
|
|
38
|
+
/** The ids from `first` to `last`, both included; empty when the range runs backwards or spans `MAX_RANGE` or more. */
|
|
39
|
+
export const spannedPhaseIds = (first: number, last: number): string[] =>
|
|
40
|
+
first > last || last - first >= MAX_RANGE
|
|
41
|
+
? []
|
|
42
|
+
: Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
|
|
43
|
+
|
|
35
44
|
/** Phases as plans and reports name them, joined by `separator`: `P3 P4`, `P19 ∥ P20`. */
|
|
36
45
|
export const phaseLabels = (ids: readonly string[], separator: string): string => ids.map(phaseLabel).join(separator)
|
|
37
46
|
|
|
@@ -39,22 +48,32 @@ const PHASES_COLUMN = 3
|
|
|
39
48
|
const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
|
|
40
49
|
const ENTRY_END = new Set([';', '.'])
|
|
41
50
|
|
|
42
|
-
type PhaseIdPatterns = { leading: RegExp; afterConnector: RegExp; connector: RegExp }
|
|
51
|
+
type PhaseIdPatterns = { leading: RegExp; rangeEnd: RegExp; afterConnector: RegExp; connector: RegExp }
|
|
43
52
|
|
|
44
53
|
const phaseIdPatterns = (language: PlanLanguage): PhaseIdPatterns => {
|
|
45
54
|
const connectors = PLAN_VOCABULARY[language].phaseConnectors.join('|')
|
|
46
55
|
const id = `(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`
|
|
47
56
|
return {
|
|
48
57
|
leading: new RegExp(`^\\s*(?:(?:${connectors})\\s+)*${id}`),
|
|
58
|
+
rangeEnd: new RegExp(`^\\s*[–-]\\s*(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+)\\b`),
|
|
49
59
|
afterConnector: new RegExp(`^\\s*(?:${connectors})\\s`),
|
|
50
60
|
connector: new RegExp(`\\b(?:${connectors})\\s+${id}`, 'g'),
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/** The ids an entry starting with `leading` names: the one id, or every id of a range `P17–P25` it opens. */
|
|
65
|
+
const leadingIds = (clause: string, leading: RegExpMatchArray, patterns: PhaseIdPatterns): string[] => {
|
|
66
|
+
const first = leading[1]
|
|
67
|
+
const end = clause.slice(leading[0].length).match(patterns.rangeEnd)
|
|
68
|
+
if (!end || !/^\d+$/.test(first)) return [first]
|
|
69
|
+
const spanned = spannedPhaseIds(Number(first), Number(end[1]))
|
|
70
|
+
return spanned.length > 0 ? spanned : [first]
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
/**
|
|
55
74
|
* The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
|
|
56
75
|
* sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
|
|
57
|
-
* with an id is running (`P1 (#<number>), P2 (#<number>)`). A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
|
|
76
|
+
* with an id is running (`P1 (#<number>), P2 (#<number>)`); an entry `P17–P25` names every phase it spans. A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
|
|
58
77
|
* P6 startet neu`) is prose.
|
|
59
78
|
*/
|
|
60
79
|
const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
@@ -69,7 +88,7 @@ const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
|
69
88
|
const leading = clause.match(patterns.leading)
|
|
70
89
|
const startsEntry: boolean = entryStart || listRunning || patterns.afterConnector.test(clause)
|
|
71
90
|
const counts: boolean = leading !== null && startsEntry
|
|
72
|
-
if (leading && counts) ids.push(leading
|
|
91
|
+
if (leading && counts) ids.push(...leadingIds(clause, leading, patterns))
|
|
73
92
|
ids.push(...[...clause.matchAll(patterns.connector)].map((match) => match[1]))
|
|
74
93
|
listRunning ||= counts
|
|
75
94
|
}
|
|
@@ -111,7 +130,8 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
|
|
|
111
130
|
/**
|
|
112
131
|
* The lanes of the sprint document's lanes table (`## Spuren` in `de`) in table order. A row whose
|
|
113
132
|
* first cell names a lane in backticks belongs to that lane; a `↳` row without a name continues
|
|
114
|
-
* the lane above it.
|
|
133
|
+
* the lane above it. A cleaned-up lane stays closed through its `↳` rows, but a later row that names it again
|
|
134
|
+
* without the mark starts the lane anew: the closed rows are history and drop out.
|
|
115
135
|
*/
|
|
116
136
|
export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
|
|
117
137
|
const lanes = new Map<string, SprintLane>()
|
|
@@ -123,7 +143,12 @@ export const parseSprintLanes = (markdown: string, language: PlanLanguage): Spri
|
|
|
123
143
|
if (!name) continue
|
|
124
144
|
const port = Number.parseInt(cells[1] ?? '', 10)
|
|
125
145
|
const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
|
|
126
|
-
|
|
146
|
+
const marked = (cells[0] ?? '').includes(laneClosed)
|
|
147
|
+
if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
|
|
148
|
+
lane.closed = false
|
|
149
|
+
lane.steps = []
|
|
150
|
+
}
|
|
151
|
+
lane.closed ||= marked
|
|
127
152
|
lane.steps.push(withoutRepeats(lane, toStep(cells, phaseId)))
|
|
128
153
|
lanes.set(name, lane)
|
|
129
154
|
previous = name
|
|
@@ -26,3 +26,7 @@ const UNSAFE_TERMINAL_TEXT = new RegExp(
|
|
|
26
26
|
* on the terminal. Apply it where such text enters, before any styling is added.
|
|
27
27
|
*/
|
|
28
28
|
export const terminalSafe = (text: string): string => text.replace(UNSAFE_TERMINAL_TEXT, '')
|
|
29
|
+
|
|
30
|
+
/** The text cut to `max` characters with an ellipsis as the last one; text that fits stays as it is. */
|
|
31
|
+
export const ellipsize = (text: string, max: number): string =>
|
|
32
|
+
text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
2
|
+
import { firstTableWithHeader, sectionLines } from './markdown'
|
|
3
|
+
import { terminalSafe } from './terminal-text'
|
|
4
|
+
|
|
5
|
+
/** 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
|
+
export type WaitingItem = {
|
|
7
|
+
since: string
|
|
8
|
+
item: string
|
|
9
|
+
open: boolean
|
|
10
|
+
/** A `file:` address the item links to, for a sent file; absent for a row of the board. */
|
|
11
|
+
url?: string
|
|
12
|
+
/** `file` for a file or an Artifact that was sent; absent for a question of the board. */
|
|
13
|
+
kind?: 'file'
|
|
14
|
+
/** The id of the record that closes it (`jh waiting close`); not shown. */
|
|
15
|
+
id?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const oneLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
|
|
19
|
+
|
|
20
|
+
/** An empty status counts as open: a row nobody closed is not dropped in silence. */
|
|
21
|
+
const isOpen = (status: string, language: PlanLanguage): boolean => {
|
|
22
|
+
const lower = status.toLowerCase()
|
|
23
|
+
return lower === '' || WAITING_VOCABULARY[language].open.some((word) => lower.startsWith(word))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 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 read. A table without the since, item or status
|
|
29
|
+
* column, a section without a table and a row without an item give nothing.
|
|
30
|
+
*/
|
|
31
|
+
export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
|
|
32
|
+
const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
|
|
33
|
+
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
34
|
+
const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
|
|
35
|
+
const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
|
|
36
|
+
if (positions.includes(-1)) return []
|
|
37
|
+
const [since, item, status] = positions
|
|
38
|
+
return rows.flatMap((cells) => {
|
|
39
|
+
const text = oneLine(cells[item] ?? '')
|
|
40
|
+
return text === ''
|
|
41
|
+
? []
|
|
42
|
+
: [{ since: oneLine(cells[since] ?? ''), item: text, open: isOpen(cells[status] ?? '', language) }]
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The open items, in the order of the board. */
|
|
47
|
+
export const openWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] =>
|
|
48
|
+
readWaitingItems(board, language).filter(({ open }) => open)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { sep } from 'node:path'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
5
|
+
import type { TaskPhase } from './current-task'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A task an agent is working on: its index among the phase's checkboxes, who works on it (null while only one agent
|
|
9
|
+
* is active in the lane), whether the herd only guesses it from words, and when the agent began it (null for a guess).
|
|
10
|
+
*/
|
|
11
|
+
export type ActiveTask = { index: number; agent: string | null; guessed: boolean; startedAt: number | null }
|
|
12
|
+
|
|
13
|
+
/** A task an agent reports that no open task of the plan names. */
|
|
14
|
+
export type UnplannedTask = { text: string; agent: string | null }
|
|
15
|
+
|
|
16
|
+
export type Assignment = { byPhase: ReadonlyMap<string, ActiveTask[]>; unplanned: UnplannedTask[] }
|
|
17
|
+
|
|
18
|
+
const openTasksOf = (phases: readonly TaskPhase[]): OpenPlanTask[] =>
|
|
19
|
+
phases.flatMap(({ id, items }) =>
|
|
20
|
+
items.flatMap((item, taskIndex) => (item.done ? [] : [{ phaseId: id, taskIndex, text: item.text }])),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lays the reported `signals` over the plan's open tasks: each task an agent named is active, grouped by phase in the
|
|
25
|
+
* order of the signals, two agents on one task share it; a signal that names no open task is unplanned. Agent text is
|
|
26
|
+
* made terminal safe here, and the agent is named only when more than one agent reports.
|
|
27
|
+
*/
|
|
28
|
+
export const assignSignals = (phases: readonly TaskPhase[], signals: readonly TaskSignal[]): Assignment => {
|
|
29
|
+
const named = new Set(signals.map(({ agent }) => terminalSafe(agent))).size > 1
|
|
30
|
+
const nameOf = (agent: string): string | null => (named ? terminalSafe(agent) : null)
|
|
31
|
+
const byPhase = new Map<string, ActiveTask[]>()
|
|
32
|
+
const unplanned: UnplannedTask[] = []
|
|
33
|
+
for (const { signal, task } of matchSignals(openTasksOf(phases), signals)) {
|
|
34
|
+
if (task === null) {
|
|
35
|
+
unplanned.push({ text: terminalSafe(signal.text), agent: nameOf(signal.agent) })
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
const tasks = byPhase.get(task.phaseId) ?? []
|
|
39
|
+
const shared = tasks.find(({ index }) => index === task.taskIndex)
|
|
40
|
+
const agent = nameOf(signal.agent)
|
|
41
|
+
if (shared) shared.agent = shared.agent && agent ? `${shared.agent}, ${agent}` : shared.agent
|
|
42
|
+
else tasks.push({ index: task.taskIndex, agent, guessed: false, startedAt: signal.startedAt })
|
|
43
|
+
byPhase.set(task.phaseId, tasks)
|
|
44
|
+
}
|
|
45
|
+
return { byPhase, unplanned }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The signals still believed: a `jh task` record counts only while it is younger than the lane's last tick, since a
|
|
50
|
+
* ticked box is the better witness that the agent moved on; Claude's own task list closes itself. Without a tick, all stay.
|
|
51
|
+
*/
|
|
52
|
+
export const freshSignals = (signals: readonly TaskSignal[], lastTickAt: number | null): TaskSignal[] =>
|
|
53
|
+
signals.filter(({ source, startedAt }) => source !== 'jh' || lastTickAt === null || startedAt >= lastTickAt)
|
|
54
|
+
|
|
55
|
+
/** The lane a directory belongs to: the first directory below `worktrees` it lies in; null anywhere else. */
|
|
56
|
+
export const laneOfDirectory = (directory: string, worktrees: string): string | null => {
|
|
57
|
+
const prefix = worktrees.endsWith(sep) ? worktrees : `${worktrees}${sep}`
|
|
58
|
+
if (!directory.startsWith(prefix)) return null
|
|
59
|
+
return directory.slice(prefix.length).split(sep)[0] || null
|
|
60
|
+
}
|
|
@@ -6,7 +6,7 @@ export type PhaseRef = { planHref: string; phaseId: string }
|
|
|
6
6
|
/** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
|
|
7
7
|
export type TickSnapshot = Record<string, Record<string, string>>
|
|
8
8
|
|
|
9
|
-
/** When a phase
|
|
9
|
+
/** When a phase was last ticked or unticked, in milliseconds, by `activityKey`. */
|
|
10
10
|
export type ActivityTimes = Readonly<Record<string, number>>
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -54,20 +54,30 @@ export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRe
|
|
|
54
54
|
}),
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
const tickCount = (ticks: string): number => [...ticks].filter((state) => state === '1').length
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The phases whose number of ticks changed between two reads of a plan `previous` read already, a phase new in `next`
|
|
61
|
+
* counted from none: a tick is activity, task lines written into a phase are not, so phases a lane only wrote down
|
|
62
|
+
* never run (17:10). A phase remembered in the format before D67 has no state to move from.
|
|
63
|
+
*/
|
|
64
|
+
const tickedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
|
|
59
65
|
Object.entries(next).flatMap(([planHref, phases]) => {
|
|
60
66
|
const before = previous[planHref]
|
|
61
67
|
if (before === undefined) return []
|
|
62
|
-
return Object.
|
|
68
|
+
return Object.entries(phases).flatMap(([phaseId, ticks]) => {
|
|
69
|
+
const old = before[phaseId]
|
|
70
|
+
if (old !== undefined && !old.startsWith(TASK_TICKS)) return []
|
|
71
|
+
return tickCount(old ?? '') === tickCount(ticks) ? [] : [{ planHref, phaseId }]
|
|
72
|
+
})
|
|
63
73
|
})
|
|
64
74
|
|
|
65
75
|
/** When each plan file was last written, in milliseconds, by plan link; a plan without an entry is bounded by the clock. */
|
|
66
76
|
export type PlanModified = Readonly<Record<string, number>>
|
|
67
77
|
|
|
68
78
|
/**
|
|
69
|
-
* When each phase last
|
|
70
|
-
* since `previous
|
|
79
|
+
* When each phase was last ticked, after a read at `now` whose ticks are `ticks`: a phase whose number of ticks changed
|
|
80
|
+
* since `previous` (`tickedPhases`), a new one in a plan `previous` read already included, takes the time its plan file was last written
|
|
71
81
|
* (`modified`), bounded by `now`, so a change first seen long after it was made is not taken for a fresh one; the
|
|
72
82
|
* others keep their time. The first read knows no movement, so without a remembered read no phase has a time.
|
|
73
83
|
*/
|
|
@@ -78,7 +88,7 @@ export const nextActivity = (
|
|
|
78
88
|
modified: PlanModified = {},
|
|
79
89
|
): ActivityTimes => {
|
|
80
90
|
if (!previous) return {}
|
|
81
|
-
const changed =
|
|
91
|
+
const changed = tickedPhases(previous.ticks, ticks)
|
|
82
92
|
return {
|
|
83
93
|
...previous.activity,
|
|
84
94
|
...Object.fromEntries(
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { parseLogEntry } from '../context/session-log'
|
|
1
2
|
import type { PlanPhase } from '../plan-format'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* A hint that a phase's task is being worked on: `taskIndex` is the task's index in the phase's checkboxes, `at` when
|
|
5
|
-
* it was seen, in milliseconds.
|
|
7
|
+
* it was seen, in milliseconds. `taskSignalsOf` derives them from what the lane's agents did lately.
|
|
6
8
|
*/
|
|
7
|
-
export type
|
|
9
|
+
export type TaskActivity = { phaseId: string; taskIndex: number; at: number }
|
|
8
10
|
|
|
9
11
|
/** A phase as `currentTasks` reads it: its id and checkboxes. */
|
|
10
12
|
export type TaskPhase = Pick<PlanPhase, 'id' | 'items'>
|
|
@@ -14,14 +16,17 @@ const isOpenTask = (phase: TaskPhase, index: number): boolean => phase.items[ind
|
|
|
14
16
|
/** The task the newest signal younger than `staleAfterMs` points at, when it is still open; null otherwise. */
|
|
15
17
|
const signalledTask = (
|
|
16
18
|
phase: TaskPhase,
|
|
17
|
-
signals: readonly
|
|
19
|
+
signals: readonly TaskActivity[],
|
|
18
20
|
now: number,
|
|
19
21
|
staleAfterMs: number,
|
|
20
22
|
): number | null => {
|
|
21
23
|
const fresh = signals
|
|
22
24
|
.filter((signal) => signal.phaseId === phase.id && now - signal.at < staleAfterMs)
|
|
23
25
|
.filter((signal) => isOpenTask(phase, signal.taskIndex))
|
|
24
|
-
const newest = fresh.reduce<
|
|
26
|
+
const newest = fresh.reduce<TaskActivity | null>(
|
|
27
|
+
(last, signal) => (last && last.at >= signal.at ? last : signal),
|
|
28
|
+
null,
|
|
29
|
+
)
|
|
25
30
|
return newest?.taskIndex ?? null
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -38,7 +43,7 @@ const firstOpenTask = ({ items }: TaskPhase): number => {
|
|
|
38
43
|
*/
|
|
39
44
|
export const currentTasks = (
|
|
40
45
|
plan: readonly TaskPhase[],
|
|
41
|
-
signals: readonly
|
|
46
|
+
signals: readonly TaskActivity[],
|
|
42
47
|
now: number,
|
|
43
48
|
staleAfterMs: number,
|
|
44
49
|
): ReadonlyMap<string, number> =>
|
|
@@ -49,3 +54,56 @@ export const currentTasks = (
|
|
|
49
54
|
return [[phase.id, signalledTask(phase, signals, now, staleAfterMs) ?? first] as const]
|
|
50
55
|
}),
|
|
51
56
|
)
|
|
57
|
+
|
|
58
|
+
const WORD = /[a-zäöüß][a-zäöüß0-9-]{5,}/g
|
|
59
|
+
/** The share of a task's distinctive words the agent used, from which on it counts as working on that task. */
|
|
60
|
+
const SIGNAL_SHARE = 0.4
|
|
61
|
+
const SIGNAL_MIN_WORDS = 3
|
|
62
|
+
/** Only the start of a task's text is read: the rest is reasons and origin. */
|
|
63
|
+
const TASK_TEXT_LENGTH = 200
|
|
64
|
+
|
|
65
|
+
const wordsOf = (text: string): Set<string> => new Set(text.toLowerCase().match(WORD) ?? [])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The signal each phase gets from what an agent of the lane did lately (`activity`, see `activityTextOf`): the open
|
|
69
|
+
* task of which the agent used at least `SIGNAL_SHARE` of the distinctive words (six letters or more, from the first
|
|
70
|
+
* 200 characters) and at least three, and clearly the most of all open tasks of the phase. Measured against the logs of
|
|
71
|
+
* the lane's agents: the prompt alone names several tasks, the words of the last entries single one out; the first open
|
|
72
|
+
* task stays the fallback when no task leads.
|
|
73
|
+
*/
|
|
74
|
+
export const taskSignalsOf = (plan: readonly TaskPhase[], activity: string, at: number): TaskActivity[] => {
|
|
75
|
+
const used = wordsOf(activity)
|
|
76
|
+
if (used.size === 0) return []
|
|
77
|
+
return plan.flatMap((phase) => {
|
|
78
|
+
const scored = phase.items.flatMap((item, taskIndex) => {
|
|
79
|
+
if (item.done) return []
|
|
80
|
+
const words = [...wordsOf(item.text.slice(0, TASK_TEXT_LENGTH))]
|
|
81
|
+
const hits = words.filter((word) => used.has(word)).length
|
|
82
|
+
return words.length > 0 && hits >= SIGNAL_MIN_WORDS && hits / words.length >= SIGNAL_SHARE
|
|
83
|
+
? [{ taskIndex, hits }]
|
|
84
|
+
: []
|
|
85
|
+
})
|
|
86
|
+
const [best, second] = scored.sort((left, right) => right.hits - left.hits)
|
|
87
|
+
return best && best.hits > (second?.hits ?? 0) ? [{ phaseId: phase.id, taskIndex: best.taskIndex, at }] : []
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const contentTexts = (content: unknown): string[] => {
|
|
92
|
+
if (typeof content === 'string') return [content]
|
|
93
|
+
if (!Array.isArray(content)) return []
|
|
94
|
+
return content.flatMap((block) => {
|
|
95
|
+
if (typeof block !== 'object' || block === null) return []
|
|
96
|
+
const { text, input } = block as { text?: unknown; input?: unknown }
|
|
97
|
+
return [typeof text === 'string' ? text : '', input === undefined ? '' : JSON.stringify(input)]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The text of an agent's last `entries` log entries (messages and tool inputs), for `taskSignalsOf`; lines that are no JSON are skipped. */
|
|
102
|
+
export const activityTextOf = (lines: readonly string[], entries: number): string =>
|
|
103
|
+
lines
|
|
104
|
+
.slice(-entries)
|
|
105
|
+
.flatMap((line) => {
|
|
106
|
+
const message = parseLogEntry(line)?.message
|
|
107
|
+
return isRecord(message) ? contentTexts(message.content) : []
|
|
108
|
+
})
|
|
109
|
+
.join(' ')
|
|
@@ -45,15 +45,20 @@ const hasOpenWork = (progress: StandingInput['progress']): boolean => {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Where a lane stands on the board.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Where a lane stands on the board. An agent, an open pull request or uncommitted changes in its worktree make a
|
|
49
|
+
* lane active (a small fix has no agent in herdr). The cleaned-up mark goes first, but uncommitted changes in the
|
|
50
|
+
* worktree beat it: work is going on, whatever the board says. Otherwise the mark closes a lane, unless an agent, a
|
|
51
|
+
* worktree or a pull request is still there, which keeps it listed. A worktree alone lists a lane. Without a
|
|
52
|
+
* worktree a lane waits while it has open work, else it is closed.
|
|
51
53
|
*/
|
|
52
54
|
export const laneStanding = (input: StandingInput): LaneStanding => {
|
|
53
55
|
const { progress, status, pullRequests } = input
|
|
54
56
|
const working = status.agent !== null || pullRequests.length > 0
|
|
55
57
|
const editedInWorktree = status.branch !== null && status.dirty > 0
|
|
56
|
-
if (progress.lane.closed)
|
|
58
|
+
if (progress.lane.closed) {
|
|
59
|
+
if (editedInWorktree) return LANE_STANDING.active
|
|
60
|
+
return working || status.branch !== null ? LANE_STANDING.noAgent : LANE_STANDING.closed
|
|
61
|
+
}
|
|
57
62
|
if (working || editedInWorktree) return LANE_STANDING.active
|
|
58
63
|
if (status.branch !== null) return LANE_STANDING.noAgent
|
|
59
64
|
return hasOpenWork(progress) ? LANE_STANDING.waiting : LANE_STANDING.closed
|