@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ab context` materializer (SPEC §8.3): a phase-scoped hydration of the
|
|
3
|
+
* gitignored `.ab/` scratch dir. Everything derives from
|
|
4
|
+
* `PHASE_SPECS[phase].inputs` — the table drives, so the CLI, the manifest,
|
|
5
|
+
* and the engine all validate against the same data (no per-phase switch
|
|
6
|
+
* sprawl). Scoping is deliberate: what a phase *can't* see is part of its
|
|
7
|
+
* design (the planner never sees code-review rounds; the reviewer sees prior
|
|
8
|
+
* findings but not the producer's session).
|
|
9
|
+
*
|
|
10
|
+
* Disposable `.ab/` content is wiped and recreated on every run — stale
|
|
11
|
+
* context is worse than no context — while legacy tracked entries are restored
|
|
12
|
+
* byte-for-byte from the index. Git's local exclude keeps generated scratch
|
|
13
|
+
* ignored without mutating the repository's own `.gitignore`.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { parseConfig } from '../config/load'
|
|
18
|
+
import type { VerifyStepConfig } from '../config/schema'
|
|
19
|
+
import type { AbEvent } from '../events/catalog'
|
|
20
|
+
import {
|
|
21
|
+
allowedTerminals,
|
|
22
|
+
phaseSpecFor,
|
|
23
|
+
type PhaseSpec,
|
|
24
|
+
type TerminalCommand,
|
|
25
|
+
} from '../kernel/phases'
|
|
26
|
+
import { reduceBuild, type BuildState } from '../kernel/reducer'
|
|
27
|
+
import {
|
|
28
|
+
isVerifyPhase,
|
|
29
|
+
verifyReportKind,
|
|
30
|
+
verifyStep,
|
|
31
|
+
type ArtifactRef,
|
|
32
|
+
type CommitRange,
|
|
33
|
+
type Feedback,
|
|
34
|
+
type Finding,
|
|
35
|
+
type Phase,
|
|
36
|
+
} from '../ontology'
|
|
37
|
+
import { KERNEL } from '../events/envelope'
|
|
38
|
+
import type { IdSource } from '../ids'
|
|
39
|
+
import {
|
|
40
|
+
allocateScratchPath,
|
|
41
|
+
ensureScratchExcluded,
|
|
42
|
+
restoreTrackedScratch,
|
|
43
|
+
trackedScratchPaths,
|
|
44
|
+
} from '../ports/workspace/phase-scratch'
|
|
45
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
46
|
+
import type { BuildStore } from '../store/types'
|
|
47
|
+
import type { CliEnv } from './env'
|
|
48
|
+
|
|
49
|
+
export interface ContextDeps {
|
|
50
|
+
store: BuildStore
|
|
51
|
+
env: CliEnv
|
|
52
|
+
/** Root of the working copy; `.ab/` is created directly under it. */
|
|
53
|
+
workspacePath: string
|
|
54
|
+
/** Present in the wired CLI; optional for non-Git materializer unit tests. */
|
|
55
|
+
exec?: Exec
|
|
56
|
+
ids?: IdSource
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Paths are relative to `.ab/`, forward-slash separated. */
|
|
60
|
+
export type MaterializedEntry = ArtifactRef | 'derived'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The manifest (§8.3): the agent's contract — `required` deposits and
|
|
64
|
+
* `allowedTerminals` — sourced from the same PHASE_SPECS table the terminal
|
|
65
|
+
* commands validate with, so skills are self-checking.
|
|
66
|
+
*/
|
|
67
|
+
export interface ContextManifest {
|
|
68
|
+
build: string
|
|
69
|
+
phase: Phase
|
|
70
|
+
round: number
|
|
71
|
+
/**
|
|
72
|
+
* Artifact kinds the phase's terminal will validate (D5). For agent-verify
|
|
73
|
+
* phases this is `verify-report:<step>`, required only on a `fail` verdict
|
|
74
|
+
* (§8.2) — listed so the agent knows the kind name up front.
|
|
75
|
+
*/
|
|
76
|
+
required: string[]
|
|
77
|
+
allowedTerminals: TerminalCommand[]
|
|
78
|
+
/** Workspace-relative path where this manifest was written. */
|
|
79
|
+
manifestPath: string
|
|
80
|
+
/** Safe workspace-relative producer notes path (implement/reconcile only). */
|
|
81
|
+
notesPath?: string
|
|
82
|
+
/** relPath (under .ab/) → source artifact ref, or 'derived'. */
|
|
83
|
+
materialized: Record<string, MaterializedEntry>
|
|
84
|
+
/** From the latest `implement.completed` (§8.3 code-review/verify inputs). */
|
|
85
|
+
commitRange?: CommitRange
|
|
86
|
+
/** Reconcile: `{baseSha}` freshly recorded by this attempt's phase start (§15.7). */
|
|
87
|
+
conflict?: { baseSha: string }
|
|
88
|
+
/** Agent-verify: the step's config from the workspace's autobuild.toml (§16.1). */
|
|
89
|
+
step?: { name: string; config: VerifyStepConfig }
|
|
90
|
+
/** The feedback this phase occurrence was started with (§15.3, §10). */
|
|
91
|
+
feedback?: Feedback
|
|
92
|
+
/** Chains a human resolved via dismiss-finding — the reviewer is told so (§15.6-B). */
|
|
93
|
+
dismissedFindingIds?: string[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requiredKinds(spec: PhaseSpec, phase: Phase): string[] {
|
|
97
|
+
if (spec.requiredArtifact !== undefined) return [spec.requiredArtifact]
|
|
98
|
+
// Agent-verify: the per-step report kind, required only on fail (§8.2).
|
|
99
|
+
if (isVerifyPhase(phase)) return [verifyReportKind(verifyStep(phase))]
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** All finding objects ever deposited, by id — verdict payloads carry them in
|
|
104
|
+
* full (§15.3), so feedback-by-id resolves without touching artifacts. */
|
|
105
|
+
function findingsById(events: AbEvent[]): Map<string, Finding> {
|
|
106
|
+
const byId = new Map<string, Finding>()
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
|
|
109
|
+
for (const finding of event.payload.findings) byId.set(finding.id, finding)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return byId
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The feedback carried by this occurrence's `*.started` event (§15.3).
|
|
117
|
+
* `implement.started`, `plan.started`, and agent `verify.started` may carry
|
|
118
|
+
* feedback — the carrier that gets answered guidance to a fresh session after
|
|
119
|
+
* a parked build re-attaches (§15.6-B). A `plan.started` without one (logs
|
|
120
|
+
* predating the field) falls
|
|
121
|
+
* back to deriving round-N feedback from the round-(N-1)
|
|
122
|
+
* `plan-review.verdict` — the plan loop's verdicts ARE its findings channel
|
|
123
|
+
* (§8.3: "prior plan rev + findings").
|
|
124
|
+
*/
|
|
125
|
+
function currentFeedback(events: AbEvent[], phase: Phase, round: number): Feedback | null {
|
|
126
|
+
if (isVerifyPhase(phase)) {
|
|
127
|
+
const step = verifyStep(phase)
|
|
128
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
129
|
+
const event = events[i]!
|
|
130
|
+
if (
|
|
131
|
+
event.type === 'verify.started' &&
|
|
132
|
+
event.payload.step === step &&
|
|
133
|
+
event.payload.attempt === round
|
|
134
|
+
) {
|
|
135
|
+
return event.payload.feedback ?? null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
if (phase === 'implement') {
|
|
141
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
142
|
+
const event = events[i]!
|
|
143
|
+
if (event.type === 'implement.started' && event.payload.round === round) {
|
|
144
|
+
return event.payload.feedback ?? null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
if (phase === 'plan') {
|
|
150
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
151
|
+
const event = events[i]!
|
|
152
|
+
if (event.type === 'plan.started' && event.payload.round === round) {
|
|
153
|
+
if (event.payload.feedback !== undefined) return event.payload.feedback
|
|
154
|
+
break // legacy start without the field — derive from the prior verdict
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (round <= 1) return null
|
|
158
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
159
|
+
const event = events[i]!
|
|
160
|
+
if (event.type === 'plan-review.verdict' && event.payload.round === round - 1) {
|
|
161
|
+
return event.payload.verdict === 'revise'
|
|
162
|
+
? { findings: event.payload.findings.map((finding) => finding.id) }
|
|
163
|
+
: null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Empty and recreate `.ab/`; callers restore any tracked legacy entries. */
|
|
171
|
+
async function wipeAbDir(abDir: string): Promise<void> {
|
|
172
|
+
await rm(abDir, { recursive: true, force: true })
|
|
173
|
+
await mkdir(abDir, { recursive: true })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function dismissedIds(state: BuildState, events: AbEvent[]): string[] {
|
|
177
|
+
const known = findingsById(events)
|
|
178
|
+
const ids = new Set<string>()
|
|
179
|
+
for (const escalation of state.answeredEscalations) {
|
|
180
|
+
if (escalation.resolution !== 'dismiss-finding') continue
|
|
181
|
+
for (const ref of escalation.refs ?? []) {
|
|
182
|
+
if (known.has(ref)) ids.add(ref)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return [...ids]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Materialize the phase's `.ab/` tree and return the manifest (also written
|
|
190
|
+
* as `.ab/context.json`).
|
|
191
|
+
*/
|
|
192
|
+
export async function buildContext(deps: ContextDeps): Promise<ContextManifest> {
|
|
193
|
+
const { store, env, workspacePath } = deps
|
|
194
|
+
const { build, phase, round } = env
|
|
195
|
+
const spec = phaseSpecFor(phase)
|
|
196
|
+
const inputs = spec.inputs
|
|
197
|
+
const events = await store.getEvents(build)
|
|
198
|
+
const state = reduceBuild(events)
|
|
199
|
+
|
|
200
|
+
// Preserve legacy tracked scratch exactly while replacing disposable files.
|
|
201
|
+
// Some adapter contract fixtures intentionally use a non-Git workspace;
|
|
202
|
+
// context hydration still works there, while publication remains Git-only.
|
|
203
|
+
const abDir = join(workspacePath, '.ab')
|
|
204
|
+
const gitWorkspace =
|
|
205
|
+
deps.exec !== undefined &&
|
|
206
|
+
(await deps.exec(['git', 'rev-parse', '--is-inside-work-tree'], { cwd: workspacePath }))
|
|
207
|
+
.exitCode === 0
|
|
208
|
+
const tracked = gitWorkspace ? await trackedScratchPaths(deps.exec!, workspacePath) : []
|
|
209
|
+
await wipeAbDir(abDir)
|
|
210
|
+
if (gitWorkspace) {
|
|
211
|
+
await restoreTrackedScratch(deps.exec!, workspacePath, tracked)
|
|
212
|
+
await ensureScratchExcluded(deps.exec!, workspacePath)
|
|
213
|
+
}
|
|
214
|
+
if (!tracked.includes('.ab/.gitignore')) await writeFile(join(abDir, '.gitignore'), '*\n')
|
|
215
|
+
|
|
216
|
+
const trackedSet = new Set(tracked)
|
|
217
|
+
const allocated = new Set<string>()
|
|
218
|
+
const locations = new Map<string, string>()
|
|
219
|
+
const locate = (desired: string): string => {
|
|
220
|
+
const prior = locations.get(desired)
|
|
221
|
+
if (prior !== undefined) return prior
|
|
222
|
+
const path = allocateScratchPath(desired, trackedSet, allocated)
|
|
223
|
+
locations.set(desired, path)
|
|
224
|
+
return path
|
|
225
|
+
}
|
|
226
|
+
const manifestRelPath = locate('context.json')
|
|
227
|
+
const notesRelPath =
|
|
228
|
+
phase === 'implement' || phase === 'reconcile' ? locate(`${phase}-notes.md`) : undefined
|
|
229
|
+
const materialized: Record<string, MaterializedEntry> = {}
|
|
230
|
+
|
|
231
|
+
async function writeDerived(desiredPath: string, content: string): Promise<void> {
|
|
232
|
+
const relPath = locate(desiredPath)
|
|
233
|
+
const target = join(abDir, relPath)
|
|
234
|
+
await mkdir(dirname(target), { recursive: true })
|
|
235
|
+
await writeFile(target, content)
|
|
236
|
+
materialized[relPath] = 'derived'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Latest rev when `rev` omitted; silently skips absent kinds — the tree is
|
|
240
|
+
* a projection of what exists, and the manifest shows what materialized. */
|
|
241
|
+
async function writeArtifact(desiredPath: string, kind: string, rev?: number): Promise<void> {
|
|
242
|
+
const artifact = await store.getArtifact(build, kind, rev)
|
|
243
|
+
if (artifact === null) return
|
|
244
|
+
const relPath = locate(desiredPath)
|
|
245
|
+
const target = join(abDir, relPath)
|
|
246
|
+
await mkdir(dirname(target), { recursive: true })
|
|
247
|
+
await writeFile(target, artifact.content)
|
|
248
|
+
materialized[relPath] = { kind: artifact.meta.kind, rev: artifact.meta.revision }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const manifest: ContextManifest = {
|
|
252
|
+
build,
|
|
253
|
+
phase,
|
|
254
|
+
round,
|
|
255
|
+
required: requiredKinds(spec, phase),
|
|
256
|
+
allowedTerminals: allowedTerminals(phase),
|
|
257
|
+
manifestPath: `.ab/${manifestRelPath}`,
|
|
258
|
+
...(notesRelPath !== undefined ? { notesPath: `.ab/${notesRelPath}` } : {}),
|
|
259
|
+
materialized,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (inputs.ticket === true) {
|
|
263
|
+
const created = events.find((event) => event.type === 'build.created')
|
|
264
|
+
if (created !== undefined && created.type === 'build.created') {
|
|
265
|
+
const ticket = created.payload.ticket
|
|
266
|
+
const lines = [
|
|
267
|
+
`# Ticket: ${ticket.title ?? ticket.id}`,
|
|
268
|
+
'',
|
|
269
|
+
`- source: ${ticket.source}`,
|
|
270
|
+
`- id: ${ticket.id}`,
|
|
271
|
+
...(ticket.url !== undefined ? [`- url: ${ticket.url}`] : []),
|
|
272
|
+
...(ticket.title !== undefined ? [`- title: ${ticket.title}`] : []),
|
|
273
|
+
'',
|
|
274
|
+
]
|
|
275
|
+
await writeDerived('ticket.md', lines.join('\n'))
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (inputs.spec === true) {
|
|
280
|
+
// §6.3 immutability: the spec a phase sees is the rev the spec.* events
|
|
281
|
+
// anchor (spec.imported/authored/revised → state.specRev), never the
|
|
282
|
+
// newest deposit — so a stray spec rev with no sanctioning event can
|
|
283
|
+
// never swap the contract downstream reviewers approve conformance to.
|
|
284
|
+
await writeArtifact('spec.md', 'spec', state.specRev)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The plan a phase sees is the rev cited by the latest `plan.completed`
|
|
288
|
+
// (D5: the terminal validates and cites this round's deposit). For
|
|
289
|
+
// 'approved' inputs the engine only advances past the plan loop on an
|
|
290
|
+
// approve verdict (§10), so the cited rev IS the approved one; for
|
|
291
|
+
// plan-review's 'latest' it is the rev under review. Pinning to the
|
|
292
|
+
// event-derived rev — never the newest deposit — keeps an out-of-loop
|
|
293
|
+
// `ab artifact put plan` from swapping in a never-reviewed plan (§6.3's
|
|
294
|
+
// drift argument, applied to the plan).
|
|
295
|
+
if (inputs.plan !== undefined) {
|
|
296
|
+
await writeArtifact('plan.md', 'plan', state.plan.artifactRev)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Producer's own prior-round artifacts (§8.3: plan sees its prior rev,
|
|
300
|
+
// implement its own prior notes) — the phase's required kind at latest rev;
|
|
301
|
+
// absent on round 1 by construction.
|
|
302
|
+
if (inputs.priorOwnArtifacts === true && spec.requiredArtifact !== undefined) {
|
|
303
|
+
await writeArtifact(`${spec.requiredArtifact}.md`, spec.requiredArtifact)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (inputs.implementNotes === true) {
|
|
307
|
+
await writeArtifact('implement-notes.md', 'implement-notes')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Verify reports at latest rev, one file per step (§8.3: routed-back
|
|
311
|
+
// failure reports for implement; the full set for finalize).
|
|
312
|
+
if (inputs.verifyReports === true) {
|
|
313
|
+
const metas = await store.listArtifacts(build)
|
|
314
|
+
const kinds = new Set(
|
|
315
|
+
metas.map((meta) => meta.kind).filter((kind) => kind.startsWith('verify-report:')),
|
|
316
|
+
)
|
|
317
|
+
for (const kind of kinds) {
|
|
318
|
+
const step = kind.slice('verify-report:'.length)
|
|
319
|
+
await writeArtifact(`verify/${step}.md`, kind)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (inputs.findings === 'current' || inputs.currentFeedback === true) {
|
|
324
|
+
const feedback = currentFeedback(events, phase, round)
|
|
325
|
+
if (feedback !== null) {
|
|
326
|
+
manifest.feedback = feedback
|
|
327
|
+
if ('findings' in feedback) {
|
|
328
|
+
// Ids → full Finding objects from the verdict that produced them.
|
|
329
|
+
const byId = findingsById(events)
|
|
330
|
+
const resolved = feedback.findings
|
|
331
|
+
.map((id) => byId.get(id))
|
|
332
|
+
.filter((finding): finding is Finding => finding !== undefined)
|
|
333
|
+
await writeDerived('findings.json', `${JSON.stringify(resolved, null, 2)}\n`)
|
|
334
|
+
} else if ('verify' in feedback) {
|
|
335
|
+
// The verify failure report routed back into the code loop (§5) — the
|
|
336
|
+
// exact rev the feedback cites, not just the latest.
|
|
337
|
+
await writeArtifact(
|
|
338
|
+
`verify/${feedback.verify.step}.md`,
|
|
339
|
+
feedback.verify.report.kind,
|
|
340
|
+
feedback.verify.report.rev,
|
|
341
|
+
)
|
|
342
|
+
} else {
|
|
343
|
+
// A human escalation answer fed in as authoritative feedback (§15.6-B).
|
|
344
|
+
await writeDerived('guidance.json', `${JSON.stringify(feedback.guidance, null, 2)}\n`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (inputs.findings === 'all-rounds') {
|
|
350
|
+
// Every prior round's findings, for `persists` marking (§15.4), plus the
|
|
351
|
+
// human-dismissed ids — the reviewer is told so (§15.6-B).
|
|
352
|
+
const loop =
|
|
353
|
+
phase === 'plan-review' ? state.reviewFindings.planReview : state.reviewFindings.codeReview
|
|
354
|
+
for (let r = 1; r < round && r <= loop.length; r += 1) {
|
|
355
|
+
await writeDerived(`history/findings-r${r}.json`, `${JSON.stringify(loop[r - 1], null, 2)}\n`)
|
|
356
|
+
}
|
|
357
|
+
const dismissed = dismissedIds(state, events)
|
|
358
|
+
manifest.dismissedFindingIds = dismissed
|
|
359
|
+
if (dismissed.length > 0) {
|
|
360
|
+
await writeDerived('dismissed-findings.json', `${JSON.stringify(dismissed, null, 2)}\n`)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Answered guidance addressed to THIS phase (§15.6-B, §11): finalize and
|
|
365
|
+
// reconcile have no producer round for the engine to feed the answer into,
|
|
366
|
+
// so `ab context` IS their delivery channel — without this the answer would
|
|
367
|
+
// be recorded but never reach any agent. Latest answer wins, matching the
|
|
368
|
+
// engine's loop-feedback rule; the file shape matches the loop producers'
|
|
369
|
+
// guidance.json so skills read one format.
|
|
370
|
+
if (inputs.answeredGuidance === true) {
|
|
371
|
+
const answered = state.answeredEscalations.filter(
|
|
372
|
+
(escalation) => escalation.resolution === 'guidance' && escalation.phase === phase,
|
|
373
|
+
)
|
|
374
|
+
const latest = answered.at(-1) // answeredEscalations is in answer order
|
|
375
|
+
if (latest !== undefined) {
|
|
376
|
+
await writeDerived(
|
|
377
|
+
'guidance.json',
|
|
378
|
+
`${JSON.stringify({ escalation: latest.id, answer: latest.answer }, null, 2)}\n`,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (inputs.commitRange === true && state.implement.commits !== undefined) {
|
|
384
|
+
manifest.commitRange = state.implement.commits
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (inputs.conflict === true) {
|
|
388
|
+
// `pr.conflicted.baseSha` is the janitor's detection-time evidence. The
|
|
389
|
+
// runner refreshes the remote base immediately before each actual run and
|
|
390
|
+
// records the execution input in reconcile.started. A crashed same-attempt
|
|
391
|
+
// rerun can therefore have multiple starts; the newest matching AB_PHASE
|
|
392
|
+
// attempt is authoritative.
|
|
393
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
394
|
+
const event = events[i]!
|
|
395
|
+
if (event.type === 'reconcile.started' && event.payload.attempt === round) {
|
|
396
|
+
manifest.conflict = { baseSha: event.payload.baseSha }
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (manifest.conflict === undefined) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`reconcile@${round} context requires a matching reconcile.started event ` +
|
|
403
|
+
'with the freshly resolved base SHA — the runner must record the phase start before launching the session',
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (inputs.stepConfig === true && isVerifyPhase(phase)) {
|
|
409
|
+
const step = verifyStep(phase)
|
|
410
|
+
const tomlPath = join(workspacePath, 'autobuild.toml')
|
|
411
|
+
const file = Bun.file(tomlPath)
|
|
412
|
+
if (!(await file.exists())) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`verify:${step} needs its step config, but ${tomlPath} does not exist — ` +
|
|
415
|
+
'autobuild.toml declares [verify.<step>] tables (SPEC §16.1)',
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
const config = parseConfig(await file.text(), tomlPath)
|
|
419
|
+
const stepConfig = config.verify.stepConfigs[step]
|
|
420
|
+
if (stepConfig === undefined) {
|
|
421
|
+
const known = Object.keys(config.verify.stepConfigs)
|
|
422
|
+
throw new Error(
|
|
423
|
+
`verify step "${step}" is not configured in autobuild.toml — ` +
|
|
424
|
+
`configured steps: ${known.join(', ') || '(none)'} (SPEC §16.1)`,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
manifest.step = { name: step, config: stepConfig }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// inputs.prTemplate (§8.3 finalize): autobuild.toml declares no PR-template
|
|
431
|
+
// table in §16.1, so there is nothing to materialize yet — the input stays
|
|
432
|
+
// in the table for when config grows one.
|
|
433
|
+
|
|
434
|
+
await writeFile(join(abDir, manifestRelPath), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
435
|
+
|
|
436
|
+
// Legacy tracked scratch is repository debt, not content Autobuild rewrites.
|
|
437
|
+
// Record it once per build from the provisioned base so harvest can groom it.
|
|
438
|
+
if (gitWorkspace && deps.ids !== undefined) {
|
|
439
|
+
const provisioned = events.find((event) => event.type === 'workspace.provisioned')
|
|
440
|
+
const marker = 'phase-scratch:tracked-at-provisioned-base'
|
|
441
|
+
const alreadyRecorded = events.some(
|
|
442
|
+
(event) => event.type === 'observation.recorded' && event.payload.refs?.includes(marker),
|
|
443
|
+
)
|
|
444
|
+
if (
|
|
445
|
+
provisioned !== undefined &&
|
|
446
|
+
provisioned.type === 'workspace.provisioned' &&
|
|
447
|
+
!alreadyRecorded
|
|
448
|
+
) {
|
|
449
|
+
const baseTracked = await trackedScratchPaths(
|
|
450
|
+
deps.exec!,
|
|
451
|
+
workspacePath,
|
|
452
|
+
provisioned.payload.base.sha,
|
|
453
|
+
)
|
|
454
|
+
if (baseTracked.length > 0) {
|
|
455
|
+
await store.append(build, {
|
|
456
|
+
actor: KERNEL,
|
|
457
|
+
type: 'observation.recorded',
|
|
458
|
+
payload: {
|
|
459
|
+
id: deps.ids('obs'),
|
|
460
|
+
kind: 'followup',
|
|
461
|
+
summary: `repository base tracks Autobuild phase scratch: ${baseTracked.join(', ')}`,
|
|
462
|
+
files: baseTracked,
|
|
463
|
+
refs: [marker],
|
|
464
|
+
},
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return manifest
|
|
470
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { DashboardBuild, DashboardHarvest, DashboardModel, EffectiveStatus } from './model'
|
|
2
|
+
|
|
3
|
+
export type DashboardBuildControl =
|
|
4
|
+
| { key: 'p'; action: 'pause'; label: 'pause' }
|
|
5
|
+
| { key: 'p'; action: 'cancel-pause'; label: 'cancel pause' }
|
|
6
|
+
| { key: 'r'; action: 'resume'; label: 'resume' }
|
|
7
|
+
|
|
8
|
+
/** Authoritative effective-status mapping consumed by every dashboard frontend. */
|
|
9
|
+
export function dashboardBuildControl(status: EffectiveStatus): DashboardBuildControl | undefined {
|
|
10
|
+
switch (status) {
|
|
11
|
+
case 'running':
|
|
12
|
+
return { key: 'p', action: 'pause', label: 'pause' }
|
|
13
|
+
case 'pausing':
|
|
14
|
+
return { key: 'p', action: 'cancel-pause', label: 'cancel pause' }
|
|
15
|
+
case 'paused':
|
|
16
|
+
case 'blocked':
|
|
17
|
+
return { key: 'r', action: 'resume', label: 'resume' }
|
|
18
|
+
case 'queued':
|
|
19
|
+
case 'resuming':
|
|
20
|
+
case 'aborting':
|
|
21
|
+
case 'cleaning':
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DashboardActionAvailability {
|
|
27
|
+
primary?: DashboardBuildControl['action']
|
|
28
|
+
abort: boolean
|
|
29
|
+
discard: boolean
|
|
30
|
+
autoMerge: boolean
|
|
31
|
+
answer: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildActionAvailability(build: DashboardBuild): DashboardActionAvailability {
|
|
35
|
+
return {
|
|
36
|
+
...(dashboardBuildControl(build.status)
|
|
37
|
+
? { primary: dashboardBuildControl(build.status)!.action }
|
|
38
|
+
: {}),
|
|
39
|
+
abort: ['queued', 'running', 'pausing', 'paused', 'resuming', 'blocked'].includes(build.status),
|
|
40
|
+
discard: build.status === 'queued',
|
|
41
|
+
autoMerge: !['aborting', 'cleaning'].includes(build.status),
|
|
42
|
+
answer: build.blockers.length > 0,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function repositoryActionAvailability(model: DashboardModel) {
|
|
47
|
+
return {
|
|
48
|
+
intake: true,
|
|
49
|
+
defaultAutoMerge: true,
|
|
50
|
+
bulkPause: model.builds.some((build) => build.status === 'running'),
|
|
51
|
+
bulkResume: model.builds.some((build) => build.status === 'paused'),
|
|
52
|
+
harvestToggle: true,
|
|
53
|
+
harvestRun: model.harvestPaused === false,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function harvestActionAvailability(harvest: DashboardHarvest | undefined) {
|
|
58
|
+
return { resume: harvest?.action === 'resume', acknowledge: harvest?.action === 'acknowledge' }
|
|
59
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Unicode text geometry shared by every dashboard compositor. */
|
|
2
|
+
|
|
3
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
4
|
+
|
|
5
|
+
export interface Grapheme {
|
|
6
|
+
/** Exact source bytes represented by this cluster. */
|
|
7
|
+
text: string
|
|
8
|
+
/** UTF-16 boundaries, suitable for String.prototype.slice. */
|
|
9
|
+
start: number
|
|
10
|
+
end: number
|
|
11
|
+
/** Terminal display-cell width. */
|
|
12
|
+
width: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Segment a string into indivisible extended grapheme clusters. */
|
|
16
|
+
export function graphemes(value: string): Grapheme[] {
|
|
17
|
+
return [...SEGMENTER.segment(value)].map(({ segment, index }) => ({
|
|
18
|
+
text: segment,
|
|
19
|
+
start: index,
|
|
20
|
+
end: index + segment.length,
|
|
21
|
+
width: Bun.stringWidth(segment),
|
|
22
|
+
}))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isTerminalControl(code: number): boolean {
|
|
26
|
+
return code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Preserve readable Unicode while making terminal control characters visible.
|
|
31
|
+
* This is display-only: callers retain and submit the original value.
|
|
32
|
+
*/
|
|
33
|
+
export function displayText(value: string): string {
|
|
34
|
+
let displayed = ''
|
|
35
|
+
for (const character of value) {
|
|
36
|
+
const code = character.codePointAt(0)!
|
|
37
|
+
displayed += isTerminalControl(code) ? `\\u{${code.toString(16)}}` : character
|
|
38
|
+
}
|
|
39
|
+
return displayed
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Display-safe clusters, with widths measured after controls are escaped. */
|
|
43
|
+
export function displayGraphemes(value: string): Grapheme[] {
|
|
44
|
+
return graphemes(displayText(value))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Width in terminal display cells. Input must not contain ANSI sequences. */
|
|
48
|
+
export function cellWidth(value: string): number {
|
|
49
|
+
return graphemes(value).reduce((sum, cluster) => sum + cluster.width, 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pad to a terminal-cell width without trimming an over-wide value. */
|
|
53
|
+
export function padEndCells(value: string, width: number): string {
|
|
54
|
+
return `${value}${' '.repeat(Math.max(0, width - cellWidth(value)))}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Split into cell-bounded rows without splitting a grapheme cluster. A cluster
|
|
59
|
+
* wider than the complete row budget is omitted, and later clusters resume.
|
|
60
|
+
*/
|
|
61
|
+
export function splitCells(value: string, width: number): string[] {
|
|
62
|
+
if (width <= 0) return []
|
|
63
|
+
const rows: string[] = []
|
|
64
|
+
let row = ''
|
|
65
|
+
let used = 0
|
|
66
|
+
for (const cluster of graphemes(value)) {
|
|
67
|
+
// At an impossible width, omission is safer than a partial cluster or an
|
|
68
|
+
// overflowing physical terminal row.
|
|
69
|
+
if (cluster.width > width) continue
|
|
70
|
+
if (cluster.width > 0 && used + cluster.width > width) {
|
|
71
|
+
rows.push(row)
|
|
72
|
+
row = ''
|
|
73
|
+
used = 0
|
|
74
|
+
}
|
|
75
|
+
row += cluster.text
|
|
76
|
+
used += cluster.width
|
|
77
|
+
}
|
|
78
|
+
if (row !== '' || rows.length === 0) rows.push(row)
|
|
79
|
+
return rows
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Normalize an arbitrary UTF-16 offset to the preceding grapheme boundary. */
|
|
83
|
+
export function graphemeBoundary(value: string, offset: number): number {
|
|
84
|
+
if (!Number.isFinite(offset)) return value.length
|
|
85
|
+
const wanted = Math.max(0, Math.min(value.length, Math.floor(offset)))
|
|
86
|
+
let boundary = 0
|
|
87
|
+
for (const cluster of graphemes(value)) {
|
|
88
|
+
if (cluster.end > wanted) return cluster.start
|
|
89
|
+
boundary = cluster.end
|
|
90
|
+
}
|
|
91
|
+
return boundary
|
|
92
|
+
}
|