Voice: rewrote REGINALD_SYSTEM and CAPTURE_SYSTEM (core) to an elevated,
dry butler register, and re-voiced his visible lines — chat greetings, the
approve/dismiss/error replies, the standup closer ('The kettle is on. Yours,
Reginald.'), the onboarding welcome, and the capture prose. Both system prompts
now also instruct him never to use an em dash.
Em-dashes: swept every user-facing string in the renderer free of em-dashes
(punctuation only, comments left untouched) via a per-file pass, plus the core
tool descriptions and the memory focus-slot placeholder ('· ' not '— '). Bare
'—' value placeholders became middots ('·'). No em-dash now renders anywhere
in the app or in Reginald's own output.
core 169 tests green (updated the memory placeholder assertion) · core + desktop
tsc clean · verified visually (posh greeting + standup closer render).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
import { inferLifecycle, schedule } from '@commitea/core'
|
|
|
|
import type { DepEdge, DepNode, DepsData } from '../../data/view-types.js'
|
|
import { formatShort } from '../dates.js'
|
|
import type { ProjectData } from './project-data.js'
|
|
|
|
/** Copy of backlog.ts's private toSchedulable — open issues → scheduler input. Not exported there. */
|
|
function toSchedulable(issues: ProjectData['issues']) {
|
|
return issues
|
|
.filter((i) => i.state === 'open')
|
|
.map((i) => ({
|
|
number: i.number,
|
|
title: i.title,
|
|
labels: i.labels,
|
|
estimateDays: i.facts.estimateDays,
|
|
priority: i.facts.priority,
|
|
assignee: i.assignee,
|
|
}))
|
|
}
|
|
|
|
function median(values: number[]): number {
|
|
if (values.length === 0) return 0
|
|
const sorted = [...values].sort((a, b) => a - b)
|
|
const mid = Math.floor(sorted.length / 2)
|
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
|
}
|
|
|
|
/**
|
|
* Real Deps-graph view: dependency topology of every issue that appears in a
|
|
* real gitea dependency edge, laid out into columns (longest dependency depth)
|
|
* and rows (index within its column, ordered by issue number). Critical path
|
|
* comes from the deterministic scheduler over the open backlog — closed issues
|
|
* never schedule, so they can't be critical, which is honest (they're done).
|
|
* The milestone node is the open milestone with the most open in-scope issues;
|
|
* when nothing links to a real milestone we fall back to the first open one,
|
|
* then to a bare '—' placeholder — never a fabricated name/date.
|
|
*/
|
|
export function depsGraphView(d: ProjectData): DepsData {
|
|
const byNumber = new Map(d.issues.map((i) => [i.number, i]))
|
|
|
|
// Participating set: anything that shows up as either end of a real edge,
|
|
// restricted to issues we actually have (dangling refs to unknown issues
|
|
// are dropped — nothing honest to draw for them).
|
|
const participating = new Set<number>()
|
|
for (const e of d.deps) {
|
|
if (byNumber.has(e.issue)) participating.add(e.issue)
|
|
if (byNumber.has(e.dependsOn)) participating.add(e.dependsOn)
|
|
}
|
|
|
|
if (participating.size === 0) {
|
|
const fallback = d.milestones.find((m) => m.state === 'open')
|
|
return {
|
|
nodes: [],
|
|
milestone: fallback
|
|
? { name: fallback.title, due: fallback.dueOn ? formatShort(new Date(fallback.dueOn)) : 'no date', col: 0, row: 0 }
|
|
: { name: '·', due: 'no date', col: 0, row: 0 },
|
|
edges: [],
|
|
critical: [],
|
|
unattached: [],
|
|
}
|
|
}
|
|
|
|
// In-scope deps: both endpoints participating.
|
|
const depsMap = new Map<number, number[]>() // issue -> its dependencies
|
|
const dependentsMap = new Map<number, number[]>() // issue -> who depends on it
|
|
for (const id of participating) {
|
|
depsMap.set(id, [])
|
|
dependentsMap.set(id, [])
|
|
}
|
|
for (const e of d.deps) {
|
|
if (participating.has(e.issue) && participating.has(e.dependsOn)) {
|
|
depsMap.get(e.issue)!.push(e.dependsOn)
|
|
dependentsMap.get(e.dependsOn)!.push(e.issue)
|
|
}
|
|
}
|
|
|
|
// Column = longest dependency depth. Cycle-guarded (shouldn't happen — real
|
|
// gitea deps are DAG-shaped in practice — but recursion must not hang).
|
|
const colMemo = new Map<number, number>()
|
|
function colOf(id: number, visiting: Set<number>): number {
|
|
const cached = colMemo.get(id)
|
|
if (cached != null) return cached
|
|
if (visiting.has(id)) return 0 // cycle guard: treat as a root
|
|
visiting.add(id)
|
|
const deps = depsMap.get(id) ?? []
|
|
const col = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => colOf(dep, visiting)))
|
|
visiting.delete(id)
|
|
colMemo.set(id, col)
|
|
return col
|
|
}
|
|
for (const id of participating) colOf(id, new Set())
|
|
|
|
// Row = index within column, ordered deterministically by issue number.
|
|
const byCol = new Map<number, number[]>()
|
|
for (const id of participating) {
|
|
const col = colMemo.get(id)!
|
|
if (!byCol.has(col)) byCol.set(col, [])
|
|
byCol.get(col)!.push(id)
|
|
}
|
|
const rowOf = new Map<number, number>()
|
|
for (const ids of byCol.values()) {
|
|
ids.sort((a, b) => a - b)
|
|
ids.forEach((id, i) => rowOf.set(id, i))
|
|
}
|
|
|
|
const maxCol = Math.max(...[...colMemo.values()])
|
|
|
|
// Critical path from the real scheduler over the open backlog.
|
|
const plan = schedule(toSchedulable(d.issues), d.deps)
|
|
const critical = new Set(plan.items.filter((i) => i.critical).map((i) => i.number))
|
|
const scheduledByNumber = new Map(plan.items.map((i) => [i.number, i]))
|
|
|
|
const nodes: DepNode[] = [...participating].sort((a, b) => a - b).map((id) => {
|
|
const issue = byNumber.get(id)!
|
|
const inf = inferLifecycle(issue, d.timelines[id] ?? [], d.today)
|
|
const scheduled = scheduledByNumber.get(id)
|
|
return {
|
|
id,
|
|
title: issue.title,
|
|
tags: issue.labels,
|
|
state: inf.column,
|
|
days: inf.steepingDays != null ? `${inf.steepingDays}d` : undefined,
|
|
col: colMemo.get(id)!,
|
|
row: rowOf.get(id)!,
|
|
// Reuse the scheduler's own rationale where one exists (open issues
|
|
// only) — never invented commentary for closed/unscheduled nodes.
|
|
rationale: scheduled?.rationale,
|
|
}
|
|
})
|
|
|
|
// Nearest open milestone with the most open in-scope issues.
|
|
const openMilestones = d.milestones.filter((m) => m.state === 'open')
|
|
let chosenMilestoneId: number | null = null
|
|
let milestone: DepsData['milestone']
|
|
const medianRow = median(nodes.map((n) => n.row))
|
|
if (openMilestones.length === 0) {
|
|
milestone = { name: '·', due: 'no date', col: maxCol + 1, row: medianRow }
|
|
} else {
|
|
let best: { id: number; title: string; dueOn: string | null | undefined; count: number } | null = null
|
|
for (const m of openMilestones) {
|
|
const count = [...participating].filter((id) => {
|
|
const issue = byNumber.get(id)!
|
|
return issue.milestone?.id === m.id && issue.state === 'open'
|
|
}).length
|
|
if (
|
|
best === null ||
|
|
count > best.count ||
|
|
(count === best.count && dueRank(m.dueOn) < dueRank(best.dueOn))
|
|
) {
|
|
best = { id: m.id, title: m.title, dueOn: m.dueOn, count }
|
|
}
|
|
}
|
|
// best is non-null since openMilestones.length > 0
|
|
chosenMilestoneId = best!.count > 0 ? best!.id : null
|
|
milestone = {
|
|
name: best!.title,
|
|
due: best!.dueOn ? formatShort(new Date(best!.dueOn)) : 'no date',
|
|
col: maxCol + 1,
|
|
row: medianRow,
|
|
}
|
|
}
|
|
|
|
const edges: DepEdge[] = []
|
|
for (const e of d.deps) {
|
|
if (!participating.has(e.issue) || !participating.has(e.dependsOn)) continue
|
|
const crit = critical.has(e.dependsOn) && critical.has(e.issue)
|
|
edges.push(crit ? { from: e.dependsOn, to: e.issue, crit: true } : { from: e.dependsOn, to: e.issue })
|
|
}
|
|
|
|
// Terminal nodes (nothing in scope depends on them) that belong to the
|
|
// chosen milestone's scope get an edge into it.
|
|
if (chosenMilestoneId != null) {
|
|
for (const id of participating) {
|
|
const dependents = dependentsMap.get(id) ?? []
|
|
if (dependents.length > 0) continue
|
|
const issue = byNumber.get(id)!
|
|
if (issue.milestone?.id !== chosenMilestoneId) continue
|
|
edges.push(critical.has(id) ? { from: id, to: 'ms', crit: true } : { from: id, to: 'ms' })
|
|
}
|
|
}
|
|
|
|
const linked = new Set<number>()
|
|
for (const e of edges) {
|
|
linked.add(e.from)
|
|
if (e.to !== 'ms') linked.add(e.to)
|
|
}
|
|
const unattached = [...participating].filter((id) => !linked.has(id)).sort((a, b) => a - b)
|
|
|
|
return { nodes, milestone, edges, critical: [...critical], unattached }
|
|
}
|
|
|
|
/** Rank a due date for "nearest" comparisons; undated milestones sort last. */
|
|
function dueRank(dueOn: string | null | undefined): number {
|
|
return dueOn ? new Date(dueOn).getTime() : Number.POSITIVE_INFINITY
|
|
}
|