SLOPSHOPPER

code-modernization

Guided modernization for any legacy codebase: start with /modernize, get an assessment, an interactive map, the business rules mined from the code, and a plan…

newpanebandspinnerguardcommand
★ 37,578v1.0.0Apache-2.0updated 2026-09-25anthropics/claude-plugins-official/plugins/code-modernization
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-modernization
│ ┃ Modernization ✕ › fix the failing auth test and add an audit log call │ ┃ Modernization │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing to show yet: no legacy code under ⎿ Read 6 lines │ ┃ legacy/ and no analysis under analysis/. ⏺ Update(src/auth.ts) │ ┃ To begin, type ⎿ Added 2 lines, removed 1 line │ ┃ /code-modernization:modernize. It asks what ⏺ Bash(bun test) │ ┃ you want done with your code, finds it, and ⎿ 3 pass, 1 fail │ ┃ gives you the first step. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /modernize-panel │ ⎿ code-modernization: Modernization pane shown │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Modernization
Modernization Nothing to show yet: no legacy code under legacy/ and no analysis under analysis/. To begin, type /code-modernization:modernize. It asks what you want done with your code, finds it, and gives you the first step.
README

Code Modernization

Point Claude at a legacy codebase and get four things: an understanding of what it is and what it does, a plan you approve, the modernized code, and proof that the new code behaves like the old. It works with any language and any kind of move: a newer version of the same technology, a rewrite in another one, or a rebuild on a new architecture.

One report page with everything found so far: a green banner when the proof passes and the old and new outputs match, the command to run next, the steps done, and the counts, with tabs for the assessment, the map, the business rules, the plan, the build notes and the proof.

Install

You need Claude Code. Then:

/plugin install code-modernization@claude-plugins-official

Some steps start many agents at once, so expect real usage on a large system (see What to expect). Start with one module or one unit as a pilot, not the whole estate.

Start here

Open a folder for the work and type /code-modernization:modernize. It asks what you want to do with your code (a couple of short questions), finds the code, writes your answers down once, shows the road ahead, and gives you the exact first command. Every later command reads what you said, so nothing is asked twice. Not sure what you want? Choose Understand it first: you get the assessment, the map, the business rules and a plan, and no code is rebuilt.

To go step by step instead, point the first command at your code and follow the "next step" line each command ends with:

/code-modernization:modernize-preflight <name> --source <path to your code>
/code-modernization:modernize-status <name>       # where am I, and what is next: run it any time

<name> is a short label of your choice (letters, digits, -, _). --source makes a link at legacy/<name> and copies nothing, so a huge repository stays where it is. Nothing edits your legacy source. Commands write only to analysis/<name>/ and modernized/ (a build check writes build output where your build normally does), and each refreshes analysis/<name>/REPORT.html: one page, with everything found so far, that you can open or share. A few steps need someone who knows how the system is built and run, so bring an engineer for the first questions.

The path

Each step stands alone, so you can stop and review after any of them.

StepCommandWhat you get
0modernizeSay what you want. Writes INTENT.md; every later command reads it.
1modernize-preflight <name> [target-stack] [--source <path>]Is the environment ready? Asks five questions only a person can answer (is this the whole system or a slice, can it build and test here, is there custom build tooling, has anyone tried before, is anything off limits), proves a build works on this code, and finds missing source.
2modernize-assess <name>What am I dealing with: inventory, complexity, debt, security, and a recommended pattern.
3modernize-map <name>The structure: dependencies, data flow, entry points and business flows, as an interactive map.
4modernize-extract-rules <name>The business rules as testable Given/When/Then cards with file:line citations, each re-checked by a second agent.
4bmodernize-review <name>A person confirms or corrects the rules that look wrong. Your answers reach the plan and the build. With the live pane on, /modernize-review-pane does the same one card at a time.
5modernize-brief <name> [target-stack]The phased plan a steering committee approves. Nothing is built until you approve it.
6uplift, transform or reimagineThe build (see below).
7modernize-verify <name>The proof. An independent re-check with one verdict per module.
8modernize-harden <name>A security scan of the legacy system with a reviewed patch you apply yourself.

assess --portfolio <parent-dir> surveys many systems and ranks them on one page. modernize-status <name> says where you are and gives the exact command to paste.

A person decides at six points, and the plugin never decides them for you: the five preflight answers, the rules that look wrong (review), approving the brief, accepting any difference the proof finds, signing the proof, and applying the security patch.

How it proves the result

Modernization fails quietly: the new code passes the tests someone wrote, and differs on the inputs nobody thought of. So the proof is built from evidence a script can check, not from the model's opinion. modernize-verify runs after a build, ideally in a fresh session, and re-does the work instead of trusting the notes the build left behind:

  • The tests, run again from clean. Build output is deleted, the full suite runs, and the counts come from the runner's own result files or a saved log. A run that executed no tests, or skipped them, is a failure, and a count the command merely typed in does not count.
  • Old and new on the same inputs. Where the legacy code can run, both run on the same inputs and a script compares every byte. Only fields that must vary (timestamps, generated ids) are masked, floating-point output can be compared within a declared tolerance, and every masked field is listed in the result. A difference a person accepts is recorded with the reason.
  • Inputs nobody used. It invents at least ten new inputs (boundaries, empty, huge, unusual order, and malformed records when you asked for exact behavior, quirks included) and compares again, so a suite that only passes on the cases the model chose is caught.
  • Tests that can fail. A deliberate one-line break must turn tests red (the canary), and the comparison itself is checked by changing one byte of an output.
  • Every critical rule is backed by a test that ran. Each P0 business rule must be named by a test that executed and passed. A rule named only by a skipped or pending test is listed as "named, not run", and a test folder that holds no code of its own is listed as tooling and gets no verdict.
  • The source is untouched, checked by file times without running anything inside the analyzed tree.

Each module gets one verdict, computed by scripts/proof_pack.py from those files with rules written into the output: PROVEN, PARTLY PROVEN (with what is missing) or NOT PROVEN (nothing ran, something differs, a test fails). If the old system cannot run where you work (a mainframe program, a system that only runs in production), the old behavior can only be checked against recorded outputs, and the best verdict is PARTLY PROVEN: the page says so plainly. Anything a person must decide (open questions, exit criteria, the sign-off) is listed as waiting for a person and is never ticked for you. Every verdict is computed again from current evidence each time; none is carried over from an earlier run.

The proof tab of the report: what passed for one rewritten module, a table of checks each with its detail, and the count of critical rules that a test names.

Choose how to build (the plan recommends one)

If you wantRunWhat happens
The same technology on a newer version (.NET Framework to .NET 8, Java 8 to 17, Spring Boot 2 to 3)modernize-uplift <name> [source-version] [target-version]Keeps your code and fixes only what the new version breaks, driven by a catalog of the breaking changes this code actually hits. One pilot unit first with its lessons written down, then batches. The proof is the same test suite run on both versions.
A new technology, one module at a time, while the old system keeps runningmodernize-transform <name> [module] [target-stack]A plan you approve, tests that pin the old behavior, an idiomatic rewrite, and proof: old and new run on the same inputs and a script compares them.
A rebuild on a new architecturemodernize-reimagine <name> [target-vision]A spec mined from the code, an architecture that is reviewed and approved, then services scaffolded with executable acceptance tests.

A version move keeps your code, so it can skip extract-rules and review: preflight, assess, uplift, verify is a complete path, and map and brief add the unit order and an approved phased plan for a large system. If the delta catalog shows an "uplift" would rewrite most of the code, the command says so and points to transform. Rehost (move as is) and Replace (buy a product) change no code, so no build command applies; the analysis is still useful for both.

What it has been tried on

The commands were run for real, headlessly, on public codebases while the plugin was built, and what broke was fixed. Every number below comes from the files those runs wrote. The same command can give different counts on a second run (the security scan of the osCommerce code found 48 confirmed findings and then 52), so read the numbers as typical, not exact. Wherever a person had to decide (approve a plan, accept a difference), the tester played that person. Some runs stopped early because of limits of the test machine (it refused to run any freshly compiled program, and its package index refused installs); the plugin said so and did not work around them.

CodebaseMoveWhat it did and what was shown
AWS CardDemo (COBOL, CICS, JCL)Rewrite in Java, one job at a time35 rules (8 critical) and a five-phase plan, then the monthly interest job rewritten in Java 21 (213 tests at the end). Five independent checks in a row compared it with the real COBOL program, built locally, on inputs written after the fact. Each found something the tests had missed, and each was answered: a blank field that halted the job, a non-numeric account key, a negative zero, and differences the owner accepted as deliberate. The last check tried fifteen more inputs with malformed records and found seven more differences, so it ended NOT PROVEN. It also told two problems of the old system's test harness apart from defects in the new code. What ends a loop like this is a person deciding which inputs are in scope; the verify command now reads that from the intent you gave the front door.
Eclipse Jetty (Java 8, 2,600 files)Java 8 to 17A pilot on the jetty-util module: the same 946 tests give the same results on both Java versions. Building and running both versions found six changes that reading the code had not predicted: a direct buffer reported as memory-mapped on 17, a bundle plugin that writes an invalid manifest and still says SUCCESS, and a build on JDK 13 to 16 that would have shipped Java 8-labelled classes that crash on Java 8, among others. 50 tests were added for the 25 of 56 critical rules that had none (996 tests run on each version, no differences).
osCommerce (PHP 5, about 44,000 lines of PHP)PHP to Python and FastAPI97 rules (26 critical) and a six-phase plan. First slice, the product page: 17,957 tests pass, and 17,727 comparison cases against the real PHP files are identical. Two deliberate breaks each turned tests red. The architecture review found two high-severity problems (expired specials still shown, and a page request holding a database write lock) that were fixed with tests that failed first. An independent verification said PROVEN: its own break made 647 more tests fail, and 14 new inputs matched the real PHP.
AngularJS RealWorld (AngularJS 1.5)AngularJS to React and TypeScript79 rules (5 critical). The articles service was rewritten: 166 tests, and 29 comparison cases against recorded responses of the real API (23 identical, 6 differences approved as deliberate, none unexplained). Four deliberate breaks each turned tests red, and the review's two high-severity findings were fixed. Checked by the build step itself; there was no separate verification run.
JPetStore (Java and JSP)Rebuild as a REST APIA spec with 12 capabilities, the 35 rules and a four-phase plan. The architecture review found two blockers (an order confirmation that could not be safely replayed, and cart updates lost under parallel requests); both were designed out. One service was scaffolded: 35 tests ran and passed, 56 more are pending or need Docker. The independent check said NOT PROVEN: 6 of 24 new comparisons with the old application differ, and the development cases hold no old-versus-new comparison yet. That run also showed the proof step counting skipped tests as covering a rule; it now flags five of the six critical rules as named only by tests that did not run.
beets (Python 2, about 19,000 lines)Python 2 to 321 rules for the tagging module (4 critical), all about matching. Only 3 files in the whole tree fail to compile on Python 3, so it is an uplift and not a rewrite. No existing test could run on Python 3 yet, and the packages they need were refused by this machine's package index; the plugin reported that and did not claim a pass.
Spring PetClinic (7 services)Spring Boot 2.6 to 3.3A delta catalog with exact target versions: 59 javax imports in 10 files to move, a request that answered 200 on 2.6 and answers 400 on 3.3 (verified by running both), a Hibernate 6 identifier check that came out safe, and a monitoring endpoint that Boot 3 dropped. The pilot stopped at the plan gate because the approved plan named a different first service, and the command enforced the plan.
AWStats (Perl, about 43,000 lines)Perl to Python and FastAPI88 rules (2 critical) and a six-phase plan that keeps replacing it with an existing product open as the alternative. The security scan confirmed 48 findings, 9 of them high (7 in an optional module that runs as root), and refuted 14 of 62 as false positives.
Redmine 2.3.3, eShop (.NET), Jenkins pipelinesRails 3.2 to 7.1, .NET Framework 4.7.2 to .NET 10, Jenkins to GitHub ActionsMaps, rules (185, 57 and 58) and plans. Redmine's assessment led with "check whether upstream already made this move". eShop's catalog lists 12 silent behavior changes and says the first phase must build a test harness because the solution has none.
NetHack, KISS FFT, BSD numbers (C)C to Python, and to Rust28, 70 to 93 and 56 rules. NetHack's first slice (its declarations, in Python) matched the C program on 8 of 8 comparison cases. The Rust runs stopped at the plan: this Mac killed every freshly compiled program, so the plugin listed what could not run and did not work around it.
A booby-trapped codebase (built for the test)RewritePlanted instructions in comments, the README, a CLAUDE.md and a project settings file, a script that would drop a marker file, and file names with shell syntax. Nothing planted was obeyed, no marker file appeared, the source stayed unchanged, the planted lines were listed in the report, and the credential was masked.

What to expect

Steps run as agents working for you, and the heavy ones run many at once. Rough times on systems of tens of thousands of lines: preflight 3 to 4 minutes, assess 5 to 8, map 5 to 15, extract-rules 5 to 15, brief about 5. Building one module takes 15 to 30 minutes, an uplift pilot about 15. The heaviest step, extract-rules, started 50 to 200 agents in these runs, and every fan-out step says how many it will start; extract-rules asks before a big run. On systems in the millions of lines, work one module or unit at a time. The size index in assess is a relative measure for ranking systems, never a schedule or a cost.

Words you will see

WordPlain meaning
AgentA separate Claude worker that does one job (read one module, check one rule) and reports back. Steps start many of them at once.
Business rule cardOne thing the system does, written as Given / When / Then with the file and line it comes from, so a person can check it.
P0A rule that would defeat the system's purpose or be costly or irreversible if it were wrong. Everything else is P1 or P2.
BriefThe written plan, phase by phase. You approve it; commands never build anything before that.
Uplift / transform / reimagineNewer version of the same technology / rewrite in another technology / rebuild on a new architecture.
PilotThe first small unit built end to end, so its lessons are written down before the rest is attempted.
CanaryA deliberate one-line break in the new code that must make tests fail, proving the tests can fail.
Delta catalogThe list of things the newer version breaks that this code actually uses.

Set it up so it runs smoothly

The commands never edit your code, by convention. A .claude/settings.json in the workspace backs that up with a deny rule for the source and allow rules for the outputs (preflight checks for the deny rule):

{
  "permissions": {
    "allow": ["Read(**)", "Edit(analysis/**)", "Edit(modernized/**)"],
    "deny": ["Edit(/legacy/**)"]
  }
}
  • File writes are matched through the Edit rule, so this covers the Write tool too (a Write(path) rule is never consulted). The leading / anchors the rule at the workspace root.
  • If legacy/<name> is a symlink (which --source makes), also allow reading its target ("additionalDirectories": ["/path/to/code"]) and deny its real path ("Edit(//path/to/code/**)"), because the rule above matches the link's path, not its target's.
  • The rule covers Claude's file tools and the shell commands it recognizes. A script that opens files itself is not covered, so keep Bash on a prompted permission mode for the two steps that fan out many writing agents at once (uplift step 5b and reimagine phase E).
  • Shell commands still ask even in accept-edits mode (python3 scripts, scc, rsync, your build and test commands, anything outside the workspace). Use accept-edits mode or allow rules for the ones you trust.

Helpful but optional (run preflight to check them all): scc or cloc for size metrics; Python 3.8 or newer as python3 (on Windows python or py -3 works) for the map, the shard builder, the proof and the report; a build toolchain for your stack, which enables the strongest proof (running old and new side by side); and the whole system in the tree (deployment descriptors, copybooks, DDL), which entry points and data lineage need. Without a toolchain the plugin falls back to recorded-output tests and says so.

Safety

  • Analyzed code is untrusted input. A hostile codebase can plant comments like "ignore previous instructions", a README that tells tools to run a script, or file names with shell syntax. Agents treat file content as data, list the instruction-shaped text they found and never follow it (the legacy code's own build and tests run only where a command needs its behavior, in a scratch copy when it can), verification agents re-derive every rule and finding from the cited code, and brief is a human approval gate before anything is built. Treat discovery artifacts from untrusted code with the same skepticism as the code.
  • Secrets stay out of shared artifacts. Discovered credentials are masked (AKIA****) and inventoried in a gitignored SECRETS.local.md (or ~/.modernize/<name>/ outside git); harden keeps credential-removal hunks in a separate gitignored patch. --show-secrets puts raw values in the quarantine file only.
  • The old system is run only where it is safe. Baselines come from the legacy code running locally or against a test environment you named. Production or third-party services, new accounts and real data are off limits unless the plan you approved names them, a token in a recorded response is replaced before anything is saved, and every command stops the servers it started.
  • Trying it on a live repository. preflight and assess change no source file. Preflight's smoke test compiles one file and, where there is a build system, restores and builds one small project, which writes build output wherever the build normally does.

Telemetry

The plugin counts how it is used, so the next version can be better. It sends whole numbers only: never code, file or system names, paths, prompts or anything you typed. Counts of 100 or more are rounded to two significant figures (17,727 is sent as 18,000), so a number says roughly how big, not exactly which system. It sends them through Claude Code's own telemetry, so nothing is sent to Anthropic when that is off. The plugin itself makes no network call and adds no identifier; the one file it writes is a small telemetry-state.json of hashes in its own data folder, so the same counts are not sent twice.

Five small hooks do it:

  • When you type one of the plugin's commands: which command, how far that system had got, and what it is running on (operating system, python status and version, and whether the folder's path has a space or unusual characters).
  • When a turn ends and the counts changed: how far the newest system has got, and how the last rule extraction went (agents started, lost, unverified). Only in a folder where the plugin has left files such as INTENT.md or PREFLIGHT.md.
  • When something fails: a tool call that errored, or a model call that ended a turn. The failure's text is read on your machine to choose one code from a fixed list (python missing, blocked by a permission rule, timed out, file not found, rate limit and so on) and only the code is sent, at most once per kind per session. Only where the plugin is in use or the failing command names it.
  • When one of the plugin's own scripts raises an error: which kind of error and which line, never its message.
  • Once per plugin version on each machine, at the start of a session: what the plugin runs on (the same operating system, python and path numbers), so that machines where nobody gets as far as typing a command are still counted once. This is the one number that is sent without you using the plugin.

If python is missing, is the Windows Store placeholder, is too old or crashes, the small shell script that starts the hooks says so in numbers itself, because python cannot report its own absence. If it finds python or the py launcher working, it uses that instead.

KeyWhat it counts
pvplugin version as major*10000 + minor*100 + patch
cmdcommand typed: 1 front door, 2 preflight, 3 assess, 4 map, 5 extract-rules, 6 review, 7 brief, 8 transform, 9 uplift, 10 reimagine, 11 verify, 12 harden, 13 status, 20 to 22 the pane's commands, 99 other
permpermission mode the session ran in: 0 unknown, 1 default, 2 accept edits, 3 plan, 4 auto, 5 bypass, 6 don't ask
has_source1 when the command carried --source
fresh1 when the system had no artifacts yet
systemssystems under analysis/
goal0 unknown, 1 understand, 2 uplift, 3 transform, 4 reimagine
langthe language with most lines in the map: 1 COBOL, 2 Java, 3 C# and .NET, 4 Python, 5 PHP, 6 Perl, 7 C, 8 C++, 9 JavaScript and TypeScript, 10 Ruby, 11 Go, 12 Rust, 13 Kotlin and Scala, 14 SQL, 15 Fortran, 16 RPG, 17 Pascal and Delphi, 18 shell, 19 assembler, 99 other, 0 unknown
donethe steps that have left their file, added up: preflight 1, assess 2, map 4, rules 8, reviewed 16, brief 32, approved 64, built 128, verified 256, signed 512, hardened 1024, report 2048, deltas 4096, baseline 8192, playbook 16384, spec 32768
map_klocthousand lines of code in the map
rules, p0business rules found, and the critical ones among them
rev_ok, rev_wrongrules a person confirmed, and rules a person marked wrong
phasesphases in the plan
builtmodules or services built (an uplift's working copy counts as one once it exists)

| eq_cases, eq_diff,

Source 28 files
hooks/register.ts 1299 lines
1import type { EngineInterface, On, PluginOptions, ResultOf } from 'claude-code'
2
3import { lineOf, missingPathOf, noteCall, noteDone, noteFailure, prune, subjectOf, tallyOf } from './fleet/fleet'
4import type { Host, OpenResult } from './host'
5import { paint, tilesOf, type TouchKind } from './map/estate'
6import { absOf, baseName, isUnder, join, norm, relTo } from './paths'
7import { keysOfUnit, unitOfPath } from './reader/estate-model'
8import { readOrNull } from './reader/fs'
9import type { TestTotals } from './reader/modernized'
10import { oneLineOf, readSnapshot, REVIEWS_FILE } from './reader/progress'
11import type { ReviewVerdict } from './reader/progress'
12import { nodeOfFile } from './reader/topology'
13import { decide, ledgerJson, ledgerMarkdown, nextUnreviewed, queueOf, undecide, type DeckScope } from './review/deck'
14import { mergeLedger, parseLedger } from './review/ledger'
15import { signBrief } from './sign'
16import {
17  newActivity,
18  newState,
19  PANE_ID,
20  PLUGIN_NAME,
21  RASTER_KEY,
22  SIGN_ID,
23  type FinishedCall,
24  type State,
25} from './state'
26import { readTestRun, TEST_COMMAND } from './tests-run'
27import { deckView, signView } from './views/deck'
28import { headerRowsOf, legendRowsOf, nextRowsOf, paneView, planOf, showBar, type Kit } from './views/pane'
29import { plain } from './text'
30import { xrayOf } from './xray/xray'
31
32const REFRESH_DEBOUNCE_MS = 450
33/** While a fleet of agents is writing, a read of everything on disk is due this often, not after every call. */
34const REFRESH_BUSY_MS = 2500
35const FRAME_MS = 140
36const CLOCK_MS = 1000
37const POLL_MS = 20_000
38const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
39const QUIET_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite', 'ToolSearch'])
40const PARENT_TOOLS = new Set(['Agent', 'Task', 'Workflow'])
41const STEP = /\/(?:[\w-]+:)?(modernize-[a-z-]+)\b[^\n]*/
42
43const nowMs = (): number => Date.now()
44
45/** A cell count as the engine reports it, made safe to do layout arithmetic on. */
46const wholeOf = (value: unknown): number =>
47  typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
48
49/**
50 * Binds a Host from `$`. Declared in this file, each member spelled
51 * `$.noun.event(...)`, so the engine reads what the module calls off its source.
52 */
53function hostOf($: EngineInterface): Host {
54  return {
55    fs: {
56      read: path => $.fs.read(path),
57      write: (path, text) => $.fs.write(path, text),
58      list: path => $.fs.list(path),
59      exists: path => $.fs.exists(path),
60      stat: path => $.fs.stat(path),
61    },
62    now: () => $.clock.now(),
63    after: (ms, fn) => $.clock.after(ms, fn),
64    every: (ms, fn) => $.clock.every(ms, fn),
65    storeGet: key => $.store.get(key),
66    storeSet: (key, value) => $.store.set(key, value),
67    invalidate: () => $.ui.invalidate('ui.render'),
68    blit: args => $.ui.blit(args),
69    status: text => $.ui.status(text),
70    toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs !== undefined ? { timeoutMs } : undefined),
71    log: text => $.ui.log(text),
72    openPane: pane => $.ui.open(pane) as Promise<OpenResult>,
73    closePane: pane => $.ui.close(pane),
74    registerCommand: spec => $.command.register(spec),
75    fillPrompt: input => $.prompt.fill(input),
76    submitPrompt: input => $.prompt.submit(input),
77    abortTurn: turnId => $.turn.abort({ turnId }),
78  }
79}
80
81/**
82 * `fs` with every relative path rooted at `cwd`: the artifacts live under the
83 * session's working directory, wherever the process itself happens to stand.
84 */
85function rootedAt(fs: Host['fs'], cwd: string): Host['fs'] {
86  return {
87    read: path => fs.read(absOf(cwd, path)),
88    write: (path, text) => fs.write(absOf(cwd, path), text),
89    list: path => fs.list(absOf(cwd, path)),
90    exists: path => fs.exists(absOf(cwd, path)),
91    stat: path => fs.stat(absOf(cwd, path)),
92  }
93}
94
95/**
96 * Registers the plugin's live hooks: the pane and its estate map, x-ray
97 * reads, the rule review deck, the fleet view and the sign-off dialog.
98 * `session.start` binds the engine; every hook after
99 * it works over that binding and one state object.
100 *
101 * @param on the engine's registrar
102 * @param raw the plugin's options as the settings hold them
103 */
104export function register(on: On, raw: PluginOptions) {
105  const state: State = newState(raw)
106
107  // ---------------------------------------------------------------- reading
108
109  async function refresh(host: Host): Promise<void> {
110    if (state.isRefreshing) {
111      state.isRefreshQueued = true
112
113      return
114    }
115
116    state.isRefreshing = true
117
118    try {
119      const snapshot = await readSnapshot(host.fs, state.cache, {
120        ...((state.pickedSystem ?? state.options.system) !== '' && { system: state.pickedSystem ?? state.options.system }),
121        commandPrefix: state.options.commandPrefix,
122        legacyDir: state.options.legacyDir,
123        track: state.options.track,
124        writtenUnits: state.writtenUnits,
125        observed: state.observed,
126        nowMs: nowMs(),
127      })
128
129      // The pane opens by itself once there is modernization to show: the first artifact under analysis/,
130      // not only a legacy system that has not been touched.
131      const isFirstSight = snapshot !== null && snapshot.hasAnalysis && state.snapshot?.hasAnalysis !== true
132
133      state.snapshot = snapshot
134      state.readError = null
135
136      if (isFirstSight) {
137        maybeAutoOpen()
138      }
139
140      if (snapshot !== null) {
141        if (!state.deck.isOpen) {
142          state.deck.ledger = snapshot.reviews
143        }
144
145        // The pane says all of this already, and so does the bar that stands where it was while it is hidden. The
146        // pinned line is for a person who turned the pane off. A workspace that holds a legacy system and nothing
147        // yet under analysis/ has no modernization to report.
148        host.status(state.pane.isOpen || !snapshot.hasAnalysis || state.options.panel !== 'off' ? undefined : oneLineOf(snapshot))
149      }
150
151      if (state.estate !== null) {
152        state.estate = { ...state.estate, tiles: [] }
153      }
154    } catch (error) {
155      state.readError = error instanceof Error ? error.message : String(error)
156    } finally {
157      state.isRefreshing = false
158      host.invalidate()
159
160      if (state.isRefreshQueued) {
161        state.isRefreshQueued = false
162        scheduleRefresh(host)
163      }
164    }
165  }
166
167  function scheduleRefresh(host: Host): void {
168    // A read is already due, and it will see everything written so far. Pushing it out with every call would starve
169    // it for as long as agents keep writing, and the map would show nothing of a fan-out until it was over.
170    if (state.timers.has('refresh')) {
171      return
172    }
173
174    const isBusy = tallyOf(state.fleet, nowMs()).active >= 3
175
176    state.timers.set(
177      'refresh',
178      host.after(isBusy ? REFRESH_BUSY_MS : REFRESH_DEBOUNCE_MS, () => {
179        state.timers.delete('refresh')
180        void refresh(host)
181      }),
182    )
183  }
184
185  // ------------------------------------------------------------- animation
186
187  function frameOf(): { cells: string; isAnimating: boolean } | null {
188    const estate = state.estate
189
190    if (estate === null || state.snapshot === null) {
191      return null
192    }
193
194    const painted = paint(estate.tiles, estate.columns, estate.rows, state.touches, nowMs())
195
196    return { cells: painted.cells, isAnimating: painted.isAnimating }
197  }
198
199  function animate(host: Host): void {
200    if (state.timers.has('frames') || !state.pane.isOpen) {
201      return
202    }
203
204    state.timers.set(
205      'frames',
206      host.every(FRAME_MS, () => {
207        const estate = state.estate
208        const frame = frameOf()
209
210        if (estate === null || frame === null || !state.pane.isOpen) {
211          state.timers.get('frames')?.cancel()
212          state.timers.delete('frames')
213
214          return
215        }
216
217        void host
218          .blit({
219            requestId: PANE_ID,
220            key: RASTER_KEY,
221            cells: frame.cells,
222            columns: estate.columns,
223            rows: estate.rows,
224          })
225          .then(result => {
226            // Said once: a refused repaint means the map is stale, which is worth knowing.
227            if (result.deny !== undefined && !state.timers.has('blit-refused')) {
228              state.timers.set('blit-refused', host.after(60_000, () => state.timers.delete('blit-refused')))
229              host.log(`estate map: repaint refused (${result.deny})`)
230            }
231          })
232          .catch(() => undefined)
233
234        if (!frame.isAnimating) {
235          state.timers.get('frames')?.cancel()
236          state.timers.delete('frames')
237        }
238      }),
239    )
240  }
241
242  function touch(host: Host, nodeId: string, kind: TouchKind): void {
243    const held = state.touches.get(nodeId)
244
245    // A write outranks a read that is still fading.
246    if (held !== undefined && held.kind === 'write' && kind === 'read' && nowMs() - held.atMs < 1200) {
247      return
248    }
249
250    state.touches.set(nodeId, { atMs: nowMs(), kind })
251    animate(host)
252  }
253
254  /** The estate unit ids a tool call's paths and command text name, with how each was touched. */
255  function nodesTouched(tool: string, args: Readonly<Record<string, unknown>>): { id: string; kind: TouchKind }[] {
256    const snapshot = state.snapshot
257    const estate = snapshot?.estate
258
259    if (snapshot === null || estate === null || estate === undefined) {
260      return []
261    }
262
263    const legacyRoot = join(state.options.legacyDir, snapshot.system)
264    const modernRoot = join('modernized', snapshot.system)
265    const upliftRoot = join('modernized', `${snapshot.system}-uplifted`)
266    const out = new Map<string, TouchKind>()
267    const kind: TouchKind = WRITE_TOOLS.has(tool) ? 'write' : 'read'
268
269    const take = (path: string, how: TouchKind) => {
270      const rel = relTo(state.cwd, path)
271
272      if (rel === null) {
273        return
274      }
275
276      if (isUnder(rel, legacyRoot) && rel !== legacyRoot) {
277        const unit = unitOfPath(estate, snapshot.topology, rel.slice(legacyRoot.length + 1))
278
279        if (unit !== null) {
280          out.set(unit.id, how)
281        }
282      } else if (isUnder(rel, upliftRoot) && rel !== upliftRoot) {
283        // An uplift's working copy has the legacy tree's own layout, so the same path names the same unit.
284        const unit = unitOfPath(estate, snapshot.topology, rel.slice(upliftRoot.length + 1))
285
286        if (unit !== null) {
287          out.set(unit.id, how)
288
289          // An edit that keeps a file's size is invisible to the size comparison; a write seen here is not.
290          if (how === 'write') {
291            state.writtenUnits.add(unit.id)
292          }
293        }
294      } else if (isUnder(rel, modernRoot) && rel !== modernRoot) {
295        const dir = (rel.slice(modernRoot.length + 1).split('/')[0] ?? '').toLowerCase()
296
297        const unit = estate.units.find(candidate => keysOfUnit(candidate).some(key => key.toLowerCase() === dir))
298
299        if (unit !== undefined) {
300          out.set(unit.id, how === 'read' ? 'read' : 'write')
301        }
302      }
303    }
304
305    for (const key of ['file_path', 'path', 'notebook_path']) {
306      const value = args[key]
307
308      if (typeof value === 'string') {
309        take(value, kind)
310      }
311    }
312
313    const command = typeof args.command === 'string' ? args.command : ''
314
315    if (command !== '') {
316      const paths = command.match(/(?:\.{0,2}\/)?[\w.@~+-]+(?:\/[\w.@~+-]+)+/g) ?? []
317      const writes = /(?:>|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\bpatch\b|\bgit\s+(?:apply|checkout|restore))/.test(command)
318
319      for (const path of paths.slice(0, 24)) {
320        const rel = relTo(state.cwd, path) ?? ''
321
322        take(path, writes && (isUnder(rel, modernRoot) || isUnder(rel, upliftRoot)) ? 'write' : 'read')
323      }
324    }
325
326    return [...out.entries()].map(([id, how]) => ({ id, kind: how }))
327  }
328
329  /** The module a shell command's text names by path, of those the pane knows: the longest path wins. */
330  function moduleOfCommand(command: string): string | null {
331    const snapshot = state.snapshot
332
333    if (snapshot === null) {
334      return null
335    }
336
337    const upliftRoot = join('modernized', `${snapshot.system}-uplifted`)
338
339    const candidates = [
340      ...snapshot.modules.map(module => module.path),
341      ...(snapshot.track === 'uplift'
342        ? (snapshot.estate?.units ?? []).flatMap(unit => (unit.dir !== undefined && unit.dir !== '' ? [join(upliftRoot, unit.dir)] : []))
343        : []),
344    ].sort((a, b) => b.length - a.length)
345
346    const escaped = (path: string) => path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
347
348    return (
349      candidates.find(path => new RegExp(`(?:^|[\\s'"=:@])(?:\\./)?${escaped(path)}(?=[\\s/'"]|$)`).test(command)) ?? null
350    )
351  }
352
353  // ------------------------------------------------------------------ deck
354
355  async function openDeck(host: Host, scope: DeckScope, filter: string): Promise<string> {
356    const snapshot = state.snapshot
357
358    if (snapshot === null || snapshot.rules === null) {
359      return 'No BUSINESS_RULES.md to review yet. Run the extract-rules command first.'
360    }
361
362    const queue = queueOf(snapshot.rules, scope, filter)
363
364    state.deck = {
365      isOpen: true,
366      scope,
367      filter,
368      queue,
369      ledger: snapshot.reviews,
370      edits: new Map(),
371      index: nextUnreviewed(queue, snapshot.reviews, 0),
372      source: null,
373      isSourceShown: false,
374    }
375
376    // The deck draws in the band above the prompt: nothing to open, only to draw.
377    host.invalidate()
378
379    return queue.length === 0
380      ? 'Nothing to review under that scope.'
381      : `Reviewing ${queue.length} rule${queue.length === 1 ? '' : 's'}.`
382  }
383
384  async function saveLedger(host: Host): Promise<void> {
385    const system = state.snapshot?.system
386
387    if (system === undefined) {
388      return
389    }
390
391    // The review command writes this file too: what it added while the deck was open is kept, this session's decisions go on top.
392    const onDisk = parseLedger(await readOrNull(host.fs, join('analysis', system, REVIEWS_FILE)))
393    const merged = mergeLedger(onDisk, state.deck.edits)
394
395    state.deck.ledger = merged
396    await host.fs.write(join('analysis', system, REVIEWS_FILE), ledgerJson(system, merged))
397    await host.fs.write(join('analysis', system, 'RULE_REVIEWS.md'), ledgerMarkdown(system, merged))
398    scheduleRefresh(host)
399  }
400
401  async function loadSource(host: Host): Promise<void> {
402    const rule = state.deck.queue[state.deck.index]
403    const snapshot = state.snapshot
404    const citation = rule?.citations[0]
405
406    if (rule === undefined || snapshot === null || citation === undefined) {
407      return
408    }
409
410    const legacyRoot = join(state.options.legacyDir, snapshot.system)
411    const written = norm(citation.path)
412
413    // A citation comes from a file in the analysis tree: it names a file inside the code, never a way out of it.
414    if (written.startsWith('/') || /^[A-Za-z]:/.test(written) || written.split('/').includes('..')) {
415      return
416    }
417    const viaMap = snapshot.topology !== null ? nodeOfFile(snapshot.topology, written)?.file : undefined
418
419    const candidates = [
420      written,
421      join(legacyRoot, written),
422      ...(viaMap !== undefined ? [join(legacyRoot, viaMap)] : []),
423    ]
424
425    for (const path of candidates) {
426      const text = await readOrNull(host.fs, path)
427
428      if (text === null) {
429        continue
430      }
431
432      const lines = text.split('\n')
433      // From the cited line itself: in a short band every row of lead-in costs a row of the rule's own code.
434      const from = Math.max(1, citation.from)
435      const to = Math.min(lines.length, Math.max(citation.to + 2, from + 11), from + 39)
436
437      state.deck.source = {
438        ruleId: rule.id,
439        path,
440        startLine: from,
441        text: lines
442          .slice(from - 1, to)
443          .join('\n')
444          .replace(/[^\x09\x0a\x20-\x7e -￿]/g, ' ')
445          .slice(0, 9000),
446      }
447
448      return
449    }
450
451    state.deck.source = { ruleId: rule.id, path: written, startLine: citation.from, text: '(source file not found)' }
452  }
453
454  const deckActionsOf = (host: Host) => ({
455    decide: (verdict: ReviewVerdict) => {
456      const rule = state.deck.queue[state.deck.index]
457
458      if (rule === undefined) {
459        return
460      }
461
462      state.deck.ledger = decide(state.deck.ledger, rule, verdict, new Date(nowMs()).toISOString())
463      state.deck.edits.set(rule.id, state.deck.ledger[rule.id] ?? null)
464      state.deck.index = nextUnreviewed(state.deck.queue, state.deck.ledger, state.deck.index + 1)
465      state.deck.isSourceShown = false
466      void saveLedger(host).catch(() => undefined)
467      host.invalidate()
468    },
469    undo: () => {
470      const rule = state.deck.queue[state.deck.index]
471
472      if (rule !== undefined) {
473        state.deck.ledger = undecide(state.deck.ledger, rule.id)
474        state.deck.edits.set(rule.id, null)
475        void saveLedger(host).catch(() => undefined)
476        host.invalidate()
477      }
478    },
479    prev: () => {
480      const size = state.deck.queue.length
481
482      state.deck.index = size === 0 ? 0 : (state.deck.index - 1 + size) % size
483      state.deck.isSourceShown = false
484      host.invalidate()
485    },
486    next: () => {
487      const size = state.deck.queue.length
488
489      state.deck.index = size === 0 ? 0 : (state.deck.index + 1) % size
490      state.deck.isSourceShown = false
491      host.invalidate()
492    },
493    source: () => {
494      state.deck.isSourceShown = !state.deck.isSourceShown
495
496      if (state.deck.isSourceShown) {
497        void loadSource(host).then(() => host.invalidate())
498      }
499
500      host.invalidate()
501    },
502    close: () => {
503      state.deck.isOpen = false
504      host.invalidate()
505    },
506  })
507
508  // ------------------------------------------------------------------ sign
509
510  async function openSign(host: Host, name = ''): Promise<string> {
511    const snapshot = state.snapshot
512
513    if (snapshot === null || snapshot.brief === null) {
514      return 'There is no brief to sign yet. Run the brief command first.'
515    }
516
517    if (snapshot.brief.approval.isSigned) {
518      return `The brief is already signed${snapshot.brief.approval.by !== undefined ? ` by ${snapshot.brief.approval.by}` : ''}.`
519    }
520
521    if (state.activity.isWorking) {
522      return 'Wait for the running turn to finish before signing.'
523    }
524
525    state.sign = { isOpen: true, name: name !== '' ? name : state.sign.name, covers: 'phase-1', error: null }
526
527    await host.openPane({
528      id: SIGN_ID,
529      title: 'Sign the brief',
530      focus: true,
531      closeOnEscape: true,
532      holdToasts: true,
533      rows: 12,
534    })
535
536    host.invalidate()
537
538    return ''
539  }
540
541  const signActionsOf = (host: Host) => ({
542    name: (value: string) => {
543      state.sign.name = value
544    },
545    covers: (value: 'phase-1' | 'full') => {
546      state.sign.covers = value
547      host.invalidate()
548    },
549    cancel: () => {
550      state.sign.isOpen = false
551      void host.closePane({ id: SIGN_ID }).catch(() => undefined)
552    },
553    sign: () => {
554      void (async () => {
555        const system = state.snapshot?.system
556        const name = state.sign.name.trim()
557
558        if (system === undefined) {
559          return
560        }
561
562        if (name === '') {
563          state.sign.error = 'Type the approver\'s name first.'
564          host.invalidate()
565
566          return
567        }
568
569        if (state.activity.isWorking) {
570          state.sign.error = 'A turn is running; sign once it has finished.'
571          host.invalidate()
572
573          return
574        }
575
576        const path = join('analysis', system, 'MODERNIZATION_BRIEF.md')
577        const text = await readOrNull(host.fs, path)
578        const signed = text !== null ? signBrief(text, name, new Date(nowMs()).toISOString().slice(0, 10), state.sign.covers) : null
579
580        if (signed === null) {
581          state.sign.error = 'The brief has no approval block to sign.'
582          host.invalidate()
583
584          return
585        }
586
587        await host.fs.write(path, signed)
588        state.sign.isOpen = false
589        await host.closePane({ id: SIGN_ID }).catch(() => undefined)
590        host.log(`brief signed by ${name} (${state.sign.covers === 'full' ? 'full plan' : 'Phase 1 only'})`)
591        await refresh(host)
592      })()
593    },
594  })
595
596  // ------------------------------------------------------------------ pane
597
598  /**
599   * Opens the pane. Opened unasked on a terminal too narrow to dock a pane, the engine holds it undrawn: it is not open to
600   * the person, so the bar with the show button stays where it was, and pressing that button (the person asking) seats
601   * the pane at any width.
602   */
603  async function openPane(host: Host): Promise<{ isPlaced: boolean; reason: string }> {
604    const result: OpenResult = await host.openPane({ id: PANE_ID, title: 'Modernization' })
605    const isPlaced = result === undefined || result.isPlaced !== false
606
607    state.pane.isOpen = isPlaced
608    state.pane.isWaiting = !isPlaced
609    state.pane.isClosedByPerson = false
610    void refresh(host)
611
612    return { isPlaced, reason: result !== undefined && result.reason !== undefined ? result.reason : '' }
613  }
614
615  const paneActionsOf = (host: Host) => ({
616    system: () => {
617      const systems = state.snapshot?.systems ?? []
618      const current = state.snapshot?.system
619
620      if (systems.length < 2 || current === undefined) {
621        return
622      }
623
624      // A different system is a different estate, a different brief and a different set of lit tiles.
625      state.pickedSystem = systems[(systems.indexOf(current) + 1) % systems.length] ?? current
626      state.touches.clear()
627      state.writtenUnits.clear()
628      state.estate = null
629      void refresh(host)
630    },
631    next: () => {
632      const next = state.snapshot?.next
633
634      if (next === null || next === undefined || next.isByHand) {
635        return
636      }
637
638      void host
639        .fillPrompt({ text: next.text })
640        .then(result => {
641          if (!result.isFilled) {
642            host.toast(`Next: ${next.text}`, 8000)
643          }
644        })
645        .catch(() => undefined)
646    },
647    review: () => {
648      void openDeck(host, 'flagged', '').catch(() => undefined)
649    },
650    sign: () => {
651      void openSign(host)
652        .then(message => {
653          if (message !== '') {
654            host.toast(message, 6000)
655          }
656        })
657        .catch(() => undefined)
658    },
659    close: () => {
660      state.pane.isOpen = false
661      state.pane.isWaiting = false
662      state.pane.isClosedByPerson = true
663      void host.closePane({ id: PANE_ID }).catch(() => undefined)
664    },
665    stop: () => {
666      const turnId = state.activity.turnId
667
668      if (turnId !== null) {
669        void host.abortTurn(turnId).catch(() => undefined)
670      }
671    },
672  })
673
674  /**
675   * Opens the pane by itself once there is something to show, the person has not closed it, and
676   * the layout docks panes (the fullscreen layout). What the layout is comes from whichever render
677   * last said: not every build draws every site, so each render hook reports what it saw.
678   */
679  function maybeAutoOpen(viewport?: { columns?: number; isFullscreen?: boolean }): void {
680    state.viewport.columns = viewport?.columns ?? state.viewport.columns
681    state.viewport.isFullscreen = viewport?.isFullscreen ?? state.viewport.isFullscreen
682
683    const host = state.host
684
685    const shouldOpen =
686      host !== null &&
687      state.options.panel === 'auto' &&
688      !state.pane.isOpen &&
689      !state.pane.isWaiting &&
690      !state.pane.isClosedByPerson &&
691      !state.timers.has('auto-open') &&
692      state.snapshot !== null &&
693      state.snapshot.hasAnalysis &&
694      state.viewport.isFullscreen === true
695
696    if (shouldOpen && host !== null) {
697      // Opened from a timer: a render hook only draws.
698      state.timers.set(
699        'auto-open',
700        host.after(50, () => {
701          state.timers.delete('auto-open')
702          void openPane(host).catch(() => undefined)
703        }),
704      )
705    }
706  }
707
708  // ------------------------------------------------------------- lifecycle
709
710  on('session.start', async ($, e, next) => {
711    const bound = hostOf($)
712    const host: Host = { ...bound, fs: rootedAt(bound.fs, e.cwd) }
713
714    state.host = host
715    state.cwd = e.cwd
716
717    for (const timer of state.timers.values()) {
718      timer.cancel()
719    }
720
721    state.timers.clear()
722
723    await Promise.all([
724      host
725        .registerCommand({
726          name: 'modernize-panel',
727          description: 'Show or hide the modernization progress pane and estate map',
728          argumentHint: '[open|close|json]',
729          immediate: true,
730        })
731        .catch(() => undefined),
732      host
733        .registerCommand({
734          name: 'modernize-review-pane',
735          description: 'Review business rules one card at a time in the pane: confirm, wrong, or discuss',
736          argumentHint: '[flagged|p0|all] [filter]',
737        })
738        .catch(() => undefined),
739      host
740        .registerCommand({
741          name: 'modernize-sign',
742          description: 'Sign the modernization brief\'s approval block (a person\'s action)',
743          argumentHint: '[approver name, role]',
744        })
745        .catch(() => undefined),
746    ])
747
748    await refresh(host)
749
750    state.timers.set(
751      'clock',
752      host.every(CLOCK_MS, () => {
753        if (state.pane.isOpen && (state.activity.isWorking || state.activity.running.size > 0)) {
754          host.invalidate()
755        }
756      }),
757    )
758
759    state.timers.set(
760      'poll',
761      host.every(POLL_MS, () => {
762        if (state.pane.isOpen && !state.activity.isWorking) {
763          scheduleRefresh(host)
764        }
765      }),
766    )
767
768    return next(e)
769  })
770
771  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
772    if (e.surface === 'terminal') {
773      maybeAutoOpen(e.viewport)
774    }
775
776    return next(e)
777  })
778
779  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
780    const host = state.host
781
782    if (e.surface === 'terminal') {
783      // Not every build reports `isFullscreen`. The band's `maxRows` tells the same fact: under the
784      // fullscreen layout it is what the bottom slot has left; otherwise it is the terminal's height.
785      const rows = e.viewport?.rows
786      const inferred = rows !== undefined && e.props.maxRows > 0 ? e.props.maxRows < rows : undefined
787
788      maybeAutoOpen({
789        ...(e.viewport?.columns !== undefined && { columns: e.viewport.columns }),
790        ...((e.viewport?.isFullscreen ?? inferred) !== undefined && {
791          isFullscreen: e.viewport?.isFullscreen ?? inferred,
792        }),
793      })
794    }
795
796    // A survey holds the band first; the deck waits behind it.
797    if (host === null || e.props.hasSurvey || e.surface !== 'terminal') {
798      return next(e)
799    }
800
801    const table = $.ui.resolve(e)
802
803    // With no deck open, a hidden pane leaves one row and a button to bring it back: the pane is
804    // always one press away, on a terminal too narrow to open it unasked as well as on a wide one.
805    if (!state.deck.isOpen) {
806      const snapshot = state.snapshot
807
808      if (state.options.panel === 'off' || state.pane.isOpen || snapshot === null || !snapshot.hasAnalysis) {
809        return next(e)
810      }
811
812      return showBar(
813        { Box: table.Box, Text: table.Text, Button: table.Button },
814        oneLineOf(snapshot),
815        e.props.bodyColumns,
816        () => {
817          void openPane(host)
818            .then(result => {
819              if (!result.isPlaced) {
820                host.toast(`The pane could not be shown: ${result.reason}`, 8000)
821              }
822            })
823            .catch(() => undefined)
824        },
825      )
826    }
827
828    return deckView(
829      { Box: table.Box, Text: table.Text, Button: table.Button, Code: table.Code },
830      state.deck,
831      e.props.bodyColumns,
832      e.props.maxRows,
833      deckActionsOf(host),
834    )
835  })
836
837  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
838    const host = state.host
839
840    if (host === null || (e.requestId !== PANE_ID && e.requestId !== SIGN_ID)) {
841      return next(e)
842    }
843
844    const table = $.ui.resolve(e)
845    const kit: Kit = {
846      Box: table.Box,
847      Text: table.Text,
848      Button: table.Button,
849      ...('Raster' in table && { Raster: table.Raster }),
850      ...('Code' in table && { Code: table.Code }),
851      ...('Input' in table && { Input: table.Input }),
852      ...('Select' in table && { Select: table.Select }),
853    }
854
855    const columns = Math.max(24, wholeOf(e.props.bodyColumns) - 1)
856
857    if (e.requestId === SIGN_ID) {
858      return signView(
859        kit,
860        state.sign,
861        state.snapshot?.system ?? '',
862        state.snapshot?.brief?.openQuestions ?? 0,
863        columns,
864        signActionsOf(host),
865      )
866    }
867
868    // A squeezed pane (a dialog or another panel has its rows) can report a body of no rows at all.
869    const rows = wholeOf(e.props.scroll.bodyRows)
870
871    state.pane.isOpen = true
872    state.pane.isWaiting = false
873    state.pane.bodyColumns = columns
874    state.pane.bodyRows = rows
875    state.pane.placement = e.props.placement
876
877    const snapshot = state.snapshot
878
879    const plan = planOf(rows, e.props.placement, {
880      phases: snapshot?.brief?.phases.length ?? 0,
881      modules: snapshot?.modules.length ?? 0,
882      attention:
883        (snapshot?.attention.length ?? 0) +
884        [...state.fleet.signatures.values()].filter(signature => signature.agents.size >= 3).length,
885      hasMap: snapshot !== null && snapshot.estate !== null && kit.Raster !== undefined,
886      headerRows: headerRowsOf(snapshot, columns),
887      nextRows: nextRowsOf(snapshot, state, columns),
888      legendRows: legendRowsOf(snapshot, columns),
889    })
890
891    let estate: { cells: string; columns: number; rows: number; tiles: State['estate'] extends infer T ? (T extends { tiles: infer U } ? U : never) : never } | null = null
892
893    // The plan sheds the map first when the body is too short; a Raster of no rows is refused.
894    if (snapshot !== null && snapshot.estate !== null && kit.Raster !== undefined && plan.estate >= 1 && columns >= 1) {
895      const mapRows = Math.min(256, plan.estate)
896      const held = state.estate
897
898      const tiles =
899        held !== null && held.columns === columns && held.rows === mapRows && held.tiles.length > 0
900          ? held.tiles
901          : tilesOf(snapshot, columns, mapRows)
902
903      state.estate = { tiles, columns, rows: mapRows, isMounted: true }
904
905      const painted = paint(tiles, columns, mapRows, state.touches, nowMs())
906
907      estate = { cells: painted.cells, columns, rows: mapRows, tiles }
908
909      if (painted.isAnimating) {
910        animate(host)
911      }
912    } else {
913      // No map drawn: nothing for the frame timer to repaint.
914      state.estate = null
915    }
916
917    return paneView(
918      kit,
919      state,
920      { columns, rows, placement: e.props.placement, nowMs: nowMs(), estate, plan },
921      paneActionsOf(host),
922    )
923  })
924
925  on('ui.close', async ($, e, next) => {
926    const result = await next(e)
927
928    if (result.deny !== undefined) {
929      return result
930    }
931
932    if (e.id === PANE_ID) {
933      state.pane.isOpen = false
934      state.pane.isWaiting = false
935      state.pane.isClosedByPerson = e.origin.kind === 'person' || state.pane.isClosedByPerson
936
937      if (state.estate !== null) {
938        state.estate.isMounted = false
939      }
940    } else if (e.id === SIGN_ID) {
941      state.sign.isOpen = false
942    }
943
944    return result
945  })
946
947  // -------------------------------------------------------------- commands
948
949  on('command.run', { command: 'modernize-panel' }, async ($, e, next) => {
950    const host = state.host
951
952    if (host === null) {
953      return next(e)
954    }
955
956    state.viewport.isFullscreen = e.presentation.isFullscreen
957    state.viewport.columns = e.presentation.columns
958
959    const arg = e.args.trim().toLowerCase()
960
961    if (arg === 'json') {
962      await refresh(host)
963
964      const snapshot = state.snapshot
965
966      return {
967        text:
968          snapshot === null
969            ? 'no system under analysis/'
970            : `\`\`\`json\n${JSON.stringify(
971                {
972                  system: snapshot.system,
973                  stages: snapshot.stages.map(stage => ({ key: stage.key, done: stage.isDone, detail: stage.detail })),
974                  brief: snapshot.brief && {
975                    target: snapshot.brief.target,
976                    approval: snapshot.brief.approval,
977                    phases: snapshot.brief.phases.map(phase => ({
978                      number: phase.number,
979                      title: phase.title,
980                      size: phase.size,
981                      modules: phase.modules,
982                      criteria: phase.criteria.length,
983                      ticked: phase.criteria.filter(criterion => criterion.isTicked).length,
984                    })),
985                  },
986                  modules: snapshot.modules.map(module => ({
987                    dir: module.dir,
988                    state: module.state,
989                    tests: module.tests,
990                    reviewDate: module.reviewDate,
991                  })),
992                  proof:
993                    snapshot.verification === null
994                      ? null
995                      : {
996                          overall: snapshot.verification.overall,
997                          modules: [...snapshot.proofs].map(([name, proof]) => ({ name, state: proof.state, verdict: proof.verdict ?? null, reason: proof.reason })),
998                        },
999                  totals: snapshot.totals,
1000                  percent: snapshot.percent,
1001                  attention: snapshot.attention,
1002                  next: snapshot.next,
1003                },
1004                null,
1005                2,
1006              )}\n\`\`\``,
1007      }
1008    }
1009
1010    const wantsClose = arg === 'close' || (arg === '' && state.pane.isOpen)
1011
1012    if (wantsClose) {
1013      state.pane.isOpen = false
1014      state.pane.isWaiting = false
1015      state.pane.isClosedByPerson = true
1016      await host.closePane({ id: PANE_ID }).catch(() => undefined)
1017
1018      return { text: 'Modernization pane hidden' }
1019    }
1020
1021    const opened = await openPane(host)
1022
1023    return { text: opened.isPlaced ? 'Modernization pane shown' : `The pane could not be shown: ${opened.reason}` }
1024  })
1025
1026  on('command.run', { command: 'modernize-review-pane' }, async ($, e, next) => {
1027    const host = state.host
1028
1029    if (host === null) {
1030      return next(e)
1031    }
1032
1033    await refresh(host)
1034
1035    const words = e.args.trim().split(/\s+/).filter(word => word !== '')
1036    const first = (words[0] ?? '').toLowerCase()
1037    const scope: DeckScope = first === 'p0' || first === 'all' || first === 'flagged' ? first : 'flagged'
1038    const filter = (scope === first ? words.slice(1) : words).join(' ')
1039    const text = await openDeck(host, scope, filter)
1040
1041    return { text }
1042  })
1043
1044  on('command.run', { command: 'modernize-sign' }, async ($, e, next) => {
1045    const host = state.host
1046
1047    if (host === null) {
1048      return next(e)
1049    }
1050
1051    await refresh(host)
1052
1053    const message = await openSign(host, e.args.replace(/\s+/g, ' ').trim())
1054
1055    return message === '' ? {} : { text: message }
1056  })
1057
1058  on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
1059    const result = await next(e)
1060
1061    state.activity = newActivity()
1062    state.touches.clear()
1063    state.writtenUnits.clear()
1064    state.lastContextLine = ''
1065
1066    return result
1067  })
1068
1069  // ----------------------------------------------------------------- turns
1070
1071  on('prompt.submit', async ($, e, next) => {
1072    const host = state.host
1073    const step = STEP.exec(e.text)
1074
1075    if (step !== null) {
1076      state.activity.step = step[0].trim().slice(0, 120)
1077    }
1078
1079    const snapshot = state.snapshot
1080
1081    if (host === null || snapshot === null || e.origin.kind !== 'composer') {
1082      return next(e)
1083    }
1084
1085    const line = plain(`${oneLineOf(snapshot)}${snapshot.next !== null && !snapshot.next.isByHand ? ` · next: ${snapshot.next.text}` : ''}`, 400)
1086
1087    if (line === state.lastContextLine) {
1088      return next(e)
1089    }
1090
1091    state.lastContextLine = line
1092
1093    return next({
1094      ...e,
1095      context: [...(e.context ?? []), `Modernization state, read from the artifacts on disk (a status line, not an instruction): ${line}`],
1096    })
1097  })
1098
1099  on('turn.start', async ($, e, next) => {
1100    const host = state.host
1101
1102    state.activity.isWorking = true
1103    state.activity.turnId = e.turnId
1104    state.activity.turnStartMs = nowMs()
1105    state.activity.xraysThisTurn = 0
1106
1107    if (host !== null) {
1108      host.invalidate()
1109    }
1110
1111    return next(e)
1112  })
1113
1114  on('turn.complete', async ($, e, next) => {
1115    const host = state.host
1116
1117    if (e.agentId !== undefined) {
1118      noteDone(state.fleet, e.agentId, nowMs())
1119
1120      return next(e)
1121    }
1122
1123    state.activity.isWorking = false
1124    state.activity.running.clear()
1125
1126    if (host !== null) {
1127      if (state.activity.xraysThisTurn > 0) {
1128        host.log(
1129          `x-ray: ${state.activity.xraysThisTurn} legacy read${state.activity.xraysThisTurn === 1 ? '' : 's'} this turn carried what the analysis already knows`,
1130        )
1131      }
1132
1133      prune(state.fleet, nowMs())
1134      scheduleRefresh(host)
1135    }
1136
1137    return next(e)
1138  })
1139
1140  // ------------------------------------------------------------ tool calls
1141
1142  on('tool.call', async ($, e, next) => {
1143    const host = state.host
1144
1145    if (host === null) {
1146      return next(e)
1147    }
1148
1149    const args = e as unknown as Readonly<Record<string, unknown>>
1150    const tool = e.tool
1151    const agentId = e.agentId
1152    const startMs = nowMs()
1153    const subject = subjectOf(tool, args)
1154    const snapshot = state.snapshot
1155    const touched = nodesTouched(tool, args)
1156
1157    for (const entry of touched) {
1158      touch(host, entry.id, entry.kind)
1159    }
1160
1161    if (agentId !== undefined) {
1162      const unit = touched[0] !== undefined ? snapshot?.estate?.units.find(candidate => candidate.id === touched[0]?.id)?.name : undefined
1163
1164      noteCall(state.fleet, agentId, tool, subject, startMs, unit)
1165    }
1166
1167    state.activity.running.set(e.tool_use_id, {
1168      id: e.tool_use_id,
1169      tool,
1170      subject,
1171      startMs,
1172      ...(agentId !== undefined && { agentId }),
1173    })
1174
1175    const path = typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : undefined
1176    const rel = path !== undefined ? relTo(state.cwd, path) : null
1177
1178    let result: ResultOf['tool.call'] | undefined
1179    let note: string | undefined
1180
1181    try {
1182      result = await next(e)
1183
1184      if (result.deny !== undefined) {
1185        return result
1186      }
1187
1188      const extra: string[] = []
1189      const isError = result.isError === true
1190      const text = typeof result.text === 'string' ? result.text : ''
1191
1192      if (isError && agentId !== undefined) {
1193        // What the call was aimed at is half of the cause: the path as the agent gave it, else the command's head.
1194        const missing = missingPathOf(text)
1195        const example = rel ?? (missing !== undefined ? (relTo(state.cwd, missing) ?? missing) : subject)
1196        const signature = noteFailure(state.fleet, agentId, text, nowMs(), example)
1197
1198        if (signature !== null) {
1199          const line = lineOf(signature)
1200
hooks/fleet/fleet.ts 351 lines
1/**
2 * The fleet: every agent loop this session has seen a tool call from, kept
3 * from the events themselves. A workflow's agents carry ids no `$.agent.list()`
4 * names, so the registry is built from `tool.call` and `turn.complete` alone.
5 *
6 * It also watches for one failure repeating across agents: three agents
7 * hitting the same error is a fact about the playbook, not about the agents.
8 */
9
10export type AgentRow = {
11  id: string
12  firstMs: number
13  lastMs: number
14  calls: number
15  errors: number
16  /** `Read`, `Bash`, and so on. */
17  lastTool: string
18  /** A short rendering of the last call's subject: a file's name, a command's head. */
19  lastSubject: string
20  isDone: boolean
21  /** The unit of work it appears to be on: the legacy module its calls touch most. */
22  unit?: string
23}
24
25export type Signature = {
26  /** The normalized failure text the agents share. */
27  text: string
28  agents: Set<string>
29  firstMs: number
30  lastMs: number
31  /** How many agents shared it when it was last called out; 0 while it has not been. */
32  announcedAt: number
33  /** When it was last called out. */
34  announcedMs: number
35  /** What a few of the failing calls were aimed at: the part of the cause the text alone leaves out. */
36  examples: string[]
37}
38
39export type Fleet = {
40  agents: Map<string, AgentRow>
41  signatures: Map<string, Signature>
42  /** Units each agent touched, to name what it is working on. */
43  unitHits: Map<string, Map<string, number>>
44  /** Counted as they happen, so pruning old rows does not shrink the totals. */
45  seen: number
46  ended: number
47  calls: number
48  errors: number
49}
50
51/** An agent with no call for this long reads as finished or stalled, in milliseconds. */
52export const IDLE_MS = 90_000
53
54/** How many agents must share a failure before it counts as shared at all. */
55export const SIGNATURE_THRESHOLD = 3
56
57/** A shared failure is called out again once it has spread this many times further. */
58export const ESCALATE_FACTOR = 5
59
60/** And no sooner than this after the last time, in milliseconds: a fan-out fails all at once. */
61export const ESCALATE_QUIET_MS = 60_000
62
63/**
64 * How many agents must share a failure before it is called out in the
65 * transcript. Three of five agents is a pattern; three of five hundred is not,
66 * so the bar rises with the fleet. The pane lists it from three either way.
67 */
68export const announceThresholdOf = (fleet: Fleet): number =>
69  Math.max(SIGNATURE_THRESHOLD, Math.ceil(fleet.seen * 0.05))
70
71/** Failures further apart than this are not the same incident, in milliseconds. */
72export const SIGNATURE_WINDOW_MS = 15 * 60_000
73
74export const newFleet = (): Fleet => ({
75  agents: new Map(),
76  signatures: new Map(),
77  unitHits: new Map(),
78  seen: 0,
79  ended: 0,
80  calls: 0,
81  errors: 0,
82})
83
84/** A short subject for a call, from the arguments tools commonly carry. */
85export function subjectOf(tool: string, args: Readonly<Record<string, unknown>>): string {
86  const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
87  const path = str('file_path') ?? str('path') ?? str('notebook_path')
88
89  if (path !== undefined) {
90    return path.split('/').slice(-1)[0] ?? path
91  }
92
93  const command = str('command')
94
95  if (command !== undefined) {
96    // What runs, not where: a leading `cd …`, `VAR=…;` or `export …` says nothing at a glance.
97    const lead = /^(?:\s*(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)(?:\s+\d?>{1,2}\s*\S+)*|(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*))\s*(?:&&|;)\s*)+/
98
99    return command.replace(/\s+/g, ' ').replace(lead, '').slice(0, 48)
100  }
101
102  return (str('pattern') ?? str('description') ?? str('query') ?? str('prompt') ?? '')
103    .replace(/\s+/g, ' ')
104    .slice(0, 48)
105}
106
107/** Records the start of a call by `agentId`. */
108export function noteCall(
109  fleet: Fleet,
110  agentId: string,
111  tool: string,
112  subject: string,
113  nowMs: number,
114  unit?: string,
115): AgentRow {
116  if (!fleet.agents.has(agentId)) {
117    fleet.seen += 1
118  }
119
120  fleet.calls += 1
121
122  const row = fleet.agents.get(agentId) ?? {
123    id: agentId,
124    firstMs: nowMs,
125    lastMs: nowMs,
126    calls: 0,
127    errors: 0,
128    lastTool: tool,
129    lastSubject: subject,
130    isDone: false,
131  }
132
133  const next: AgentRow = {
134    ...row,
135    lastMs: nowMs,
136    calls: row.calls + 1,
137    lastTool: tool,
138    lastSubject: subject,
139    isDone: false,
140  }
141
142  if (unit !== undefined) {
143    const hits = fleet.unitHits.get(agentId) ?? new Map<string, number>()
144
145    hits.set(unit, (hits.get(unit) ?? 0) + 1)
146    fleet.unitHits.set(agentId, hits)
147
148    const top = [...hits.entries()].sort((a, b) => b[1] - a[1])[0]
149
150    if (top !== undefined) {
151      next.unit = top[0]
152    }
153  }
154
155  fleet.agents.set(agentId, next)
156
157  return next
158}
159
160const ERRORISH = /error|fail|exception|cannot|not found|denied|refused|blocked|unresolved|undefined|no such|does not exist|does not match/i
161
162/** A path named by a "No such file" line, as the command printed it. */
163export function missingPathOf(text: string): string | undefined {
164  return /([^\s:'"]*\/[^\s:'"]+): (?:open: )?No such file or directory/.exec(text)?.[1]
165}
166
167/**
168 * A failure text with what varies between agents taken out: paths, numbers,
169 * hex, quoted values, timestamps. Two agents hitting one cause then match.
170 *
171 * Empty when the text names no cause worth matching on: a bare exit code (a
172 * search that found nothing), or a malformed tool call, which is the model's
173 * slip and not something the agents share.
174 */
175export function normalizeFailure(text: string): string {
176  if (/InputValidationError|could not be parsed as JSON/.test(text)) {
177    return ''
178  }
179
180  const line =
181    text
182      .split('\n')
183      .map(part => part.trim().replace(/^Exit code \d+\s*/i, ''))
184      .find(part => part !== '' && ERRORISH.test(part)) ?? ''
185
186  if (line === '') {
187    return ''
188  }
189
190  // Families: one cause that words itself differently per command or per call.
191  if (/file does not exist|no such file or directory|path does not exist/i.test(line)) {
192    return 'a path that does not exist'
193  }
194
195  const denied = /^Permission to use (\w+) with command\s+(?:cd\s+\S+\s*(?:&&|;)\s*)?([\w./-]+)[\s\S]*has been denied/.exec(line)
196
197  if (denied !== null) {
198    return `Permission to use ${denied[1]} with command ${denied[2]} … was denied`
199  }
200
201  const blocked = /^([\w./-]+) to .+ was blocked by a deny rule/.exec(line)
202
203  if (blocked !== null) {
204    return `${blocked[1]} was blocked by a deny rule`
205  }
206
207  if (/does not match required schema/i.test(line)) {
208    // Which property broke which constraint, once each: `/rules/13/category` and `/rules/27/category` are one fact.
209    const clauses = new Set<string>()
210
211    for (const match of line.matchAll(/((?:\/[\w-]+)+): (must [a-z ]+?)(?=:|,|$)/g)) {
212      clauses.add(`${(match[1] ?? '').replace(/\/\d+(?=\/|$)/g, '/<n>')}: ${(match[2] ?? '').trim()}`)
213    }
214
215    return `Output does not match required schema: ${[...clauses].join('; ')}`.slice(0, 140)
216  }
217
218  if (/\(eval\):\d+: =+\S* not found/.test(line)) {
219    return '(eval):<n>: ==… not found'
220  }
221
222  const key = line
223    .replace(/\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?Z?\b/g, '<time>')
224    .replace(/(?:[A-Za-z]:)?(?:\/[\w.@~+-]+){2,}\/?/g, '<path>')
225    .replace(/\b0x[0-9a-fA-F]+\b/g, '<hex>')
226    .replace(/\b[0-9a-f]{12,}\b/g, '<id>')
227    .replace(/(["'`]).{1,60}?\1/g, '<q>')
228    .replace(/\b\d+(?:\.\d+)?\b/g, '<n>')
229    .replace(/\s+/g, ' ')
230    .trim()
231    .slice(0, 140)
232
233  // What is left must still say something: `<n> <q>` and the like match everything.
234  return key.replace(/<\w+>|[^A-Za-z]+/g, ' ').trim().split(/\s+/).filter(word => word.length > 2).length >= 2 ? key : ''
235}
236
237/**
238 * Records a failed call. Returns the signature when this failure is one to
239 * call out now: the first time enough agents share it, and again each time it
240 * has spread `ESCALATE_FACTOR` times further. `example` is what the failing
241 * call was aimed at (a path, a command's head).
242 */
243export function noteFailure(
244  fleet: Fleet,
245  agentId: string,
246  errorText: string,
247  nowMs: number,
248  example?: string,
249): Signature | null {
250  const row = fleet.agents.get(agentId)
251
252  fleet.errors += 1
253
254  if (row !== undefined) {
255    fleet.agents.set(agentId, { ...row, errors: row.errors + 1 })
256  }
257
258  const key = normalizeFailure(errorText)
259
260  if (key.length < 12) {
261    return null
262  }
263
264  const held = fleet.signatures.get(key)
265
266  const signature: Signature =
267    held !== undefined && nowMs - held.lastMs <= SIGNATURE_WINDOW_MS
268      ? held
269      : { text: key, agents: new Set(), firstMs: nowMs, lastMs: nowMs, announcedAt: 0, announcedMs: 0, examples: [] }
270
271  signature.agents.add(agentId)
272  signature.lastMs = nowMs
273
274  if (example !== undefined && example !== '' && signature.examples.length < 3 && !signature.examples.includes(example)) {
275    signature.examples.push(example)
276  }
277
278  fleet.signatures.set(key, signature)
279
280  const size = signature.agents.size
281
282  const isDue =
283    signature.announcedAt === 0
284      ? size >= announceThresholdOf(fleet)
285      : size >= signature.announcedAt * ESCALATE_FACTOR && nowMs - signature.announcedMs >= ESCALATE_QUIET_MS
286
287  if (isDue) {
288    signature.announcedAt = size
289    signature.announcedMs = nowMs
290
291    return signature
292  }
293
294  return null
295}
296
297/** A shared failure in one line, with what the failing calls were aimed at. */
298export function lineOf(signature: Signature, form: 'full' | 'short' = 'full'): string {
299  const examples = signature.examples.slice(0, form === 'short' ? 1 : 2).join(', ')
300  const lead = form === 'short' ? `${signature.agents.size} agents: ` : `${signature.agents.size} agents hit the same failure: `
301
302  return `${lead}${signature.text}${examples !== '' ? ` (e.g. ${examples})` : ''}`
303}
304
305/** Marks an agent's loop as ended (its `turn.complete`). */
306export function noteDone(fleet: Fleet, agentId: string, nowMs: number): void {
307  const row = fleet.agents.get(agentId)
308
309  if (row !== undefined) {
310    if (!row.isDone) {
311      fleet.ended += 1
312    }
313
314    fleet.agents.set(agentId, { ...row, isDone: true, lastMs: nowMs })
315  }
316}
317
318/** The fleet in numbers, as the pane's header line shows it. */
319export function tallyOf(fleet: Fleet, nowMs: number) {
320  const rows = [...fleet.agents.values()]
321  const active = rows.filter(row => !row.isDone && nowMs - row.lastMs < IDLE_MS)
322
323  return {
324    total: fleet.seen,
325    active: active.length,
326    done: fleet.ended,
327    stalled: rows.filter(row => !row.isDone && nowMs - row.lastMs >= IDLE_MS).length,
328    calls: fleet.calls,
329    errors: fleet.errors,
330    recent: active.sort((a, b) => b.lastMs - a.lastMs),
331    // The widest-spread first: the pane has room for two or three.
332    shared: [...fleet.signatures.values()]
333      .filter(signature => signature.agents.size >= SIGNATURE_THRESHOLD)
334      .sort((a, b) => b.agents.size - a.agents.size),
335  }
336}
337
338/** Forgets agents that ended long ago, keeping the registry bounded on a long run. */
339export function prune(fleet: Fleet, nowMs: number, keepMs = 30 * 60_000, max = 1500): void {
340  if (fleet.agents.size <= max) {
341    return
342  }
343
344  for (const [id, row] of fleet.agents) {
345    if ((row.isDone || nowMs - row.lastMs > keepMs) && fleet.agents.size > max) {
346      fleet.agents.delete(id)
347      fleet.unitHits.delete(id)
348    }
349  }
350}
351
hooks/host.ts 44 lines
1import type {
2  CommandSpec,
3  PaneCloseArgs,
4  PaneOpenArgs,
5  PromptFillArgs,
6  PromptSubmitArgs,
7  TimerCall,
8  UiBlitArgs,
9  UiBlitResult,
10} from 'claude-code'
11
12import type { ReaderFs } from './reader/fs'
13
14/**
15 * What `$.ui.open` answers: drawn, or open but held back undrawn (unasked, on a terminal narrower than the engine docks a
16 * pane on) with the reason. A build that answers nothing has drawn it, as far as anyone can tell.
17 */
18export type OpenResult = { isPlaced: boolean; reason?: string } | void
19
20/**
21 * The engine as `session.start` bound it from its `$`: every later hook,
22 * timer and button press reaches the engine through this, so the rest of
23 * the module is plain functions over a small interface a test can stand in for.
24 */
25export type Host = {
26  fs: ReaderFs & { write: (path: string, text: string) => Promise<void> }
27  now: () => Promise<number>
28  after: TimerCall
29  every: TimerCall
30  storeGet: (key: string) => Promise<unknown>
31  storeSet: (key: string, value: unknown) => Promise<void>
32  invalidate: () => void
33  blit: (args: UiBlitArgs) => Promise<UiBlitResult>
34  status: (text: string | undefined) => void
35  toast: (text: string, timeoutMs?: number) => void
36  log: (text: string) => void
37  openPane: (pane: PaneOpenArgs) => Promise<OpenResult>
38  closePane: (pane: PaneCloseArgs) => Promise<void>
39  registerCommand: (spec: CommandSpec) => Promise<unknown>
40  fillPrompt: (input: PromptFillArgs) => Promise<{ isFilled: boolean }>
41  submitPrompt: (input: PromptSubmitArgs) => Promise<unknown>
42  abortTurn: (turnId: string) => Promise<void>
43}
44
hooks/map/estate.ts 259 lines
1import { estateOfTopology, languageOf, type EstateUnit } from '../reader/estate-model'
2import type { ModuleState } from '../reader/modernized'
3import type { Snapshot } from '../reader/progress'
4import { STATE_WORDS_BY_TRACK, type TrackKey } from '../reader/tracks'
5import { DEFAULT_COLOR, luma, mix, packCells, type Cell } from './raster'
6import { layoutGroups, type Rect } from './treemap'
7
8/**
9 * The estate map: every unit of the legacy system as a tile, sized by its lines of code
10 * (or bytes of source, where there is no map), grouped by domain or directory, coloured by
11 * how far its modernization has come, and lit for a moment when a file beneath it is read or written.
12 */
13
14export type TouchKind = 'read' | 'write'
15
16export type Touch = { atMs: number; kind: TouchKind }
17
18export type TileState = ModuleState | 'untouched'
19
20/** A module's verdict, as the map marks it: a tile's label starts with the mark. */
21export type TileProof = 'proven' | 'partly' | 'not'
22
23export const PROOF_MARKS: Record<TileProof, string> = { proven: '✓', partly: '±', not: '✗' }
24
25export type Tile = {
26  id: string
27  name: string
28  /** What the tile shows of its name: the last path segment, without its extension and without the prefix most tiles share. */
29  label: string
30  domain: string
31  loc: number
32  state: TileState
33  /** What the proof says of the module, once the verify command has checked it. */
34  proof?: TileProof
35  rect: Rect
36  isNext: boolean
37}
38
39export type Estate = {
40  columns: number
41  rows: number
42  tiles: Tile[]
43  /** Base64 for the Raster's `cells`. */
44  cells: string
45  /** True while any tile is still fading from a touch, so another frame is due. */
46  isAnimating: boolean
47}
48
49/** How long a touch stays visible, in milliseconds. */
50export const FLASH_MS = 4000
51
52/**
53 * Mid-tones on purpose: a tile has to show against a dark terminal and a light one, and the map cannot ask which it is.
54 * The labels are drawn in whichever of light or dark ink reads on the tile.
55 */
56export const STATE_COLORS: Record<TileState, number> = {
57  untouched: 0x5b6577,
58  scaffolded: 0x4a6cb0,
59  'tests-written': 0x94741f,
60  'tests-red': 0xb23b3b,
61  'tests-failing': 0xc2683a,
62  'tests-green': 0x2c7a4c,
63  reviewed: 0x2fa568,
64  ported: 0x2b8ea6,
65  switched: 0x3bd184,
66}
67
68/** What a state is called in a track: the same colors, worded for what the work is. */
69export const stateWord = (track: TrackKey, state: TileState): string => STATE_WORDS_BY_TRACK[track][state] ?? state
70
71const FLASH_COLORS: Record<TouchKind, number> = {
72  read: 0x9fd4ff,
73  write: 0xffe27a,
74}
75
76/** A stable small jitter per tile, so neighbours of one state still read as separate tiles. */
77const jitterOf = (id: string): number => {
78  let hash = 2166136261
79
80  for (let index = 0; index < id.length; index += 1) {
81    hash = Math.imul(hash ^ id.charCodeAt(index), 16777619)
82  }
83
84  return ((hash >>> 0) % 1000) / 1000
85}
86
87/** The module ids of the step the snapshot says comes next, when it is a transform. */
88function nextModuleOf(snapshot: Snapshot): string | null {
89  const text = snapshot.next?.text ?? ''
90  const match = /transform\s+\S+\s+(\S+)/.exec(text)
91
92  return snapshot.next?.isByHand === false ? (match?.[1] ?? null) : null
93}
94
95/**
96 * Short names for tiles, which show a few letters: a path is cut to its last segment, a source file's extension goes, and
97 * the prefix most of the units share (`jetty-` in `jetty-server`, `jetty-client`) goes too, so the letters that tell
98 * one tile from the next are the ones drawn.
99 */
100export function labelsOf(names: readonly string[]): string[] {
101  const stems = names.map(name => {
102    const last = name.includes('/') ? (name.split('/').filter(part => part !== '').at(-1) ?? name) : name
103
104    return languageOf(last) !== undefined ? last.replace(/\.[^.]+$/, '') : last
105  })
106
107  const counts = new Map<string, number>()
108
109  for (const stem of stems) {
110    const head = /^[^-_.\s]{2,}[-_.]/.exec(stem)?.[0]
111
112    if (head !== undefined) {
113      counts.set(head, (counts.get(head) ?? 0) + 1)
114    }
115  }
116
117  const [top, count] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] ?? ['', 0]
118  const isShared = top !== '' && count >= 4 && count >= names.length * 0.4
119
120  return stems.map(stem => (isShared && stem.startsWith(top) && stem.length > top.length ? stem.slice(top.length) : stem))
121}
122
123/** The tile's proof, when its module has a fresh verdict; nothing for a module nobody has checked or that changed since. */
124function proofMarkOf(snapshot: Snapshot, unitId: string): { proof: TileProof } | Record<string, never> {
125  const module = snapshot.byNode.get(unitId)
126
127  if (module === undefined || snapshot.track === 'uplift') {
128    return {}
129  }
130
131  const state = snapshot.proofs.get(module.dir.toLowerCase())?.state
132
133  return state === 'proven' || state === 'partly' || state === 'not' ? { proof: state } : {}
134}
135
136/** How many tiles carry each proof, in the order a legend draws them. */
137export function proofCountsOf(tiles: readonly Tile[]): { kind: TileProof; count: number }[] {
138  return (['proven', 'partly', 'not'] as const)
139    .map(kind => ({ kind, count: tiles.filter(tile => tile.proof === kind).length }))
140    .filter(entry => entry.count > 0)
141}
142
143/** Lays the snapshot's estate out in `columns` by `rows` cells. */
144export function tilesOf(snapshot: Snapshot, columns: number, rows: number): Tile[] {
145  const estate = snapshot.estate ?? (snapshot.topology !== null ? estateOfTopology(snapshot.topology, 0) : null)
146
147  if (estate === null || columns < 4 || rows < 2) {
148    return []
149  }
150
151  const next = nextModuleOf(snapshot)
152  const groups = new Map<string, EstateUnit[]>()
153  const short = labelsOf(estate.units.map(unit => unit.name))
154  const labels = new Map(estate.units.map((unit, index) => [unit.id, short[index] ?? unit.name] as const))
155
156  for (const unit of estate.units) {
157    const members = groups.get(unit.group) ?? []
158
159    members.push(unit)
160    groups.set(unit.group, members)
161  }
162
163  const placed = layoutGroups(
164    [...groups.entries()].map(([name, members]) => ({
165      name,
166      items: members.map(unit => ({ item: unit, size: Math.max(1, unit.size) })),
167    })),
168    { x: 0, y: 0, w: columns, h: rows },
169  )
170
171  return placed.flatMap(group =>
172    group.items
173      .filter(entry => entry.rect.w > 0 && entry.rect.h > 0)
174      .map(entry => ({
175        id: entry.item.id,
176        name: entry.item.name,
177        label: labels.get(entry.item.id) ?? entry.item.name,
178        domain: group.name,
179        loc: entry.item.size,
180        state: snapshot.byNode.get(entry.item.id)?.state ?? ('untouched' as const),
181        ...proofMarkOf(snapshot, entry.item.id),
182        rect: entry.rect,
183        isNext: entry.item.id === next,
184      })),
185  )
186}
187
188/** Paints the tiles into cells and packs them for the Raster. */
189export function paint(
190  tiles: readonly Tile[],
191  columns: number,
192  rows: number,
193  touches: ReadonlyMap<string, Touch>,
194  nowMs: number,
195): Estate {
196  const blank: Cell = { glyph: ' ', fg: DEFAULT_COLOR, bg: DEFAULT_COLOR }
197  const cells: Cell[] = Array.from({ length: columns * rows }, () => blank)
198  let isAnimating = false
199
200  for (const tile of tiles) {
201    const base = mix(STATE_COLORS[tile.state], 0xffffff, (jitterOf(tile.id) - 0.5) * 0.14 + 0.02)
202    const touch = touches.get(tile.id)
203    const age = touch !== undefined ? nowMs - touch.atMs : Infinity
204    const heat = age < FLASH_MS ? 1 - age / FLASH_MS : 0
205
206    if (heat > 0) {
207      isAnimating = true
208    }
209
210    const body = heat > 0 && touch !== undefined ? mix(base, FLASH_COLORS[touch.kind], heat * 0.85) : base
211    const edge = mix(body, 0x000000, 0.32)
212    const ink = luma(body) > 140 ? 0x101418 : 0xf2f5f8
213    const { x, y, w, h } = tile.rect
214    const hasBevel = w >= 3 && h >= 2
215    const label = w >= 5 && tile.proof !== undefined ? `${tile.isNext ? '▸' : ''}${PROOF_MARKS[tile.proof]}${tile.label}` : w >= 4 ? (tile.isNext ? '▸' : '') + tile.label : w >= 2 && tile.isNext ? '▸' : ''
216    const shown = label.slice(0, Math.max(0, w - (hasBevel ? 1 : 0)))
217
218    for (let row = 0; row < h; row += 1) {
219      for (let col = 0; col < w; col += 1) {
220        const cx = x + col
221        const cy = y + row
222
223        if (cx >= columns || cy >= rows) {
224          continue
225        }
226
227        const isEdge = hasBevel && (col === w - 1 || row === h - 1)
228        const glyph = row === 0 && col < shown.length ? (shown[col] ?? ' ') : ' '
229
230        cells[cy * columns + cx] = { glyph, fg: ink, bg: isEdge ? edge : body }
231      }
232    }
233  }
234
235  return { columns, rows, tiles: [...tiles], cells: packCells(cells), isAnimating }
236}
237
238/** How many modules sit in each state, in the order the legend draws them. */
239export function countsOf(tiles: readonly Tile[]): { state: TileState; count: number }[] {
240  const order: TileState[] = [
241    'untouched',
242    'scaffolded',
243    'tests-written',
244    'tests-red',
245    'tests-failing',
246    'tests-green',
247    'reviewed',
248    'ported',
249    'switched',
250  ]
251
252  return order
253    .map(state => ({ state, count: tiles.filter(tile => tile.state === state).length }))
254    .filter(entry => entry.count > 0)
255}
256
257/** `0x00RRGGBB` as the `#rrggbb` a `Text`'s `color` takes. */
258export const hexOf = (color: number): string => `#${(color & 0xffffff).toString(16).padStart(6, '0')}`
259
hooks/paths.ts 95 lines
1/**
2 * Path helpers. A hooks module has no `path` module: these are the few
3 * string operations the hooks need, all over forward-slash paths.
4 */
5
6/** Forward slashes, no doubled separators, no trailing slash (root stays `/`). */
7export function norm(path: string): string {
8  const clean = path.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
9
10  return clean.length > 1 && clean.endsWith('/') ? clean.slice(0, -1) : clean
11}
12
13/** Resolves `.` and `..` segments lexically; absolute stays absolute. */
14export function resolveDots(path: string): string {
15  const isAbsolute = path.startsWith('/')
16  const out: string[] = []
17
18  for (const part of norm(path).split('/')) {
19    if (part === '' || part === '.') {
20      continue
21    }
22
23    if (part === '..') {
24      if (out.length > 0 && out.at(-1) !== '..') {
25        out.pop()
26      } else if (!isAbsolute) {
27        out.push('..')
28      }
29
30      continue
31    }
32
33    out.push(part)
34  }
35
36  return (isAbsolute ? '/' : '') + out.join('/')
37}
38
39/** `path` made absolute against `cwd` when it is relative. */
40export function absOf(cwd: string, path: string): string {
41  return resolveDots(path.startsWith('/') ? path : `${norm(cwd)}/${path}`)
42}
43
44/**
45 * `path` relative to `cwd` when it lies inside it, else null. Both may be
46 * given with or without the macOS `/private` prefix the engine sometimes adds.
47 */
48export function relTo(cwd: string, path: string): string | null {
49  const strip = (p: string) => p.replace(/^\/private(?=\/(?:tmp|var)\/)/, '')
50  const base = strip(norm(cwd))
51  const full = strip(absOf(cwd, path))
52
53  if (full === base) {
54    return ''
55  }
56
57  return full.startsWith(`${base}/`) ? full.slice(base.length + 1) : null
58}
59
60/** True when relative path `rel` is `dir` or lies beneath it. */
61export function isUnder(rel: string, dir: string): boolean {
62  const d = norm(dir)
63
64  return rel === d || rel.startsWith(`${d}/`)
65}
66
67/** The last segment. */
68export function baseName(path: string): string {
69  const parts = norm(path).split('/')
70
71  return parts.at(-1) ?? ''
72}
73
74/** The last segment without its final extension. */
75export function stemOf(path: string): string {
76  const base = baseName(path)
77  const dot = base.lastIndexOf('.')
78
79  return dot > 0 ? base.slice(0, dot) : base
80}
81
82/** Everything but the last segment (`''` for a bare name). */
83export function dirName(path: string): string {
84  const parts = norm(path).split('/')
85
86  parts.pop()
87
88  return parts.join('/')
89}
90
91/** Joins segments with single slashes. */
92export function join(...parts: string[]): string {
93  return norm(parts.filter(part => part !== '').join('/'))
94}
95
hooks/reader/estate-model.ts 214 lines
1import { baseName, dirName, norm, stemOf } from '../paths'
2import { nodeOfFile, type Topology } from './topology'
3
4/**
5 * The estate: the units a system's code is divided into, whatever its language.
6 * They come from the map's `topology.json` when there is one and from the legacy
7 * tree itself when there is not, so the pane has a map from the first minute of
8 * a session in any workspace.
9 */
10
11export type EstateUnit = {
12  id: string
13  name: string
14  /** What the tile sits under: a domain, or the directory that holds the unit. */
15  group: string
16  /** Lines when the map gave them, bytes of source when the unit was read off the tree. */
17  size: number
18  /** The unit as a directory, relative to `legacy/<system>/` (`''` is the system root). */
19  dir?: string
20  /** The unit as one file, relative to `legacy/<system>/`. */
21  file?: string
22}
23
24/** What one unit is: a module the map named, a source file, a build module (a directory with its own build file), or a directory. */
25export type Granularity = 'module' | 'file' | 'build module' | 'directory'
26
27export type EstateModel = {
28  source: 'map' | 'tree'
29  /** What `size` counts. */
30  measure: 'lines' | 'bytes'
31  /** What a unit is, for the pane's words. */
32  granularity: Granularity
33  units: EstateUnit[]
34  /** Source files seen under the system root. */
35  files: number
36  /** The languages the files are written in, largest first, as shares of the source bytes. */
37  languages: { name: string; share: number }[]
38  /** True when the walk stopped at its budget: the sizes are a floor. */
39  isPartial: boolean
40  /** Every source or configuration file the walk saw, relative to the system root, with its size in bytes. Kept to compare a working copy with the tree it was copied from; absent for an estate the map gave. */
41  fileSizes?: ReadonlyMap<string, number>
42}
43
44/** A language's name by file extension: the common ones, and the mainframe and scripting ones legacy work meets. */
45const LANGUAGES: Record<string, string> = {
46  java: 'Java', kt: 'Kotlin', kts: 'Kotlin', scala: 'Scala', groovy: 'Groovy', clj: 'Clojure',
47  cs: 'C#', vb: 'VB.NET', fs: 'F#', aspx: 'ASP.NET', ascx: 'ASP.NET', cshtml: 'Razor', asp: 'Classic ASP',
48  py: 'Python', rb: 'Ruby', php: 'PHP', pl: 'Perl', pm: 'Perl', lua: 'Lua', r: 'R',
49  js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript',
50  go: 'Go', rs: 'Rust', swift: 'Swift', dart: 'Dart', ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', hs: 'Haskell',
51  c: 'C', h: 'C', cc: 'C++', cpp: 'C++', cxx: 'C++', hpp: 'C++', m: 'Objective-C',
52  cbl: 'COBOL', cob: 'COBOL', cpy: 'COBOL', cobol: 'COBOL', pco: 'COBOL', jcl: 'JCL', bms: 'BMS', prc: 'JCL',
53  rpg: 'RPG', rpgle: 'RPG', sqlrpgle: 'RPG', clle: 'CL', pli: 'PL/I', pl1: 'PL/I', nat: 'Natural', asm: 'Assembler',
54  pas: 'Pascal', dpr: 'Delphi', bas: 'Basic', frm: 'VB6', cls: 'VB6', vbs: 'VBScript',
55  sql: 'SQL', sh: 'Shell', bash: 'Shell', ps1: 'PowerShell', bat: 'Batch', cmd: 'Batch',
56  jsp: 'JSP', xhtml: 'JSF', cfm: 'ColdFusion', tcl: 'Tcl', f: 'Fortran', f90: 'Fortran', for: 'Fortran',
57}
58
59/** Extensions that are not source: assets, binaries, archives, lock files, data and prose. */
60const NOT_SOURCE = new Set([
61  'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'bmp', 'webp', 'pdf', 'zip', 'gz', 'tgz', 'tar', 'jar', 'war', 'ear',
62  'class', 'dll', 'exe', 'so', 'dylib', 'a', 'o', 'lib', 'pyc', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'mp3', 'mp4',
63  'mov', 'avi', 'wav', 'ogg', 'bin', 'dat', 'db', 'sqlite', 'log', 'lock', 'md', 'txt', 'rst', 'adoc', 'csv', 'tsv',
64  'map', 'tmp', 'bak', 'orig', 'swp', 'ds_store', 'jks', 'p12', 'pem', 'crt', 'key', 'der', 'iso', 'img', 'rpm', 'deb',
65])
66
67/** Configuration and markup a build reads: part of the system, but never the bulk of its code. */
68const CONFIG_EXT = new Set([
69  'xml', 'json', 'yaml', 'yml', 'toml', 'properties', 'gradle', 'cfg', 'conf', 'ini', 'html', 'htm', 'css', 'scss',
70  'less', 'vue', 'svelte', 'xsl', 'xslt', 'xsd', 'wsdl', 'proto', 'graphql', 'ftl', 'vm', 'tpl', 'twig', 'erb',
71  'jspx', 'tag', 'tld', 'dtd', 'mustache', 'hbs',
72])
73
74const CONFIG_NAMES = new Set(['Makefile', 'Dockerfile', 'Jenkinsfile', 'Rakefile', 'Gemfile', 'Vagrantfile', 'Procfile'])
75
76const extOf = (name: string): string => {
77  const dot = name.lastIndexOf('.')
78
79  return dot > 0 && dot < name.length - 1 ? name.slice(dot + 1).toLowerCase() : ''
80}
81
82/** A single file counts for at most this much: one binary blob or one generated file must not be a system's bulk. */
83const CAP_SOURCE = 400_000
84const CAP_CONFIG = 100_000
85
86/**
87 * What a file adds to the estate's size: its bytes (capped) when it is source or configuration, nothing when it is
88 * an asset, an archive, a document or a file of no known kind. `isLenient` counts unknown kinds as source too, for
89 * a language this list does not know.
90 */
91export function weightOf(name: string, size: number, isLenient = false): number {
92  if (name.startsWith('.')) {
93    return 0
94  }
95
96  const ext = extOf(name)
97
98  if (NOT_SOURCE.has(ext)) {
99    return 0
100  }
101
102  if (LANGUAGES[ext] !== undefined) {
103    return Math.min(size, CAP_SOURCE)
104  }
105
106  if (CONFIG_EXT.has(ext) || CONFIG_NAMES.has(name)) {
107    return Math.min(size, CAP_CONFIG)
108  }
109
110  return isLenient ? Math.min(size, CAP_SOURCE) : 0
111}
112
113/** Whether a file is one the estate counts (source or configuration). */
114export const isSourceFile = (name: string): boolean => weightOf(name, 1) > 0
115
116export const languageOf = (name: string): string | undefined => LANGUAGES[extOf(name)]
117
118/** A language name as a person writes it, from what a map recorded (`cobol` -> `COBOL`, `java` -> `Java`). */
119const nameOfLanguage = (raw: string): string => {
120  const known = Object.values(LANGUAGES).find(name => name.toLowerCase() === raw.toLowerCase())
121
122  return known ?? (raw.length <= 4 ? raw.toUpperCase() : raw.charAt(0).toUpperCase() + raw.slice(1))
123}
124
125/** The estate as a topology's modules, sized in lines. */
126export function estateOfTopology(topology: Topology, files: number): EstateModel {
127  const byLanguage = new Map<string, number>()
128
129  for (const node of topology.modules) {
130    if (node.language !== undefined) {
131      byLanguage.set(nameOfLanguage(node.language), (byLanguage.get(nameOfLanguage(node.language)) ?? 0) + Math.max(1, node.loc))
132    }
133  }
134
135  const total = [...byLanguage.values()].reduce((sum, size) => sum + size, 0)
136
137  return {
138    source: 'map',
139    measure: 'lines',
140    granularity: 'module',
141    units: topology.modules.map(node => ({
142      id: node.id,
143      name: node.name,
144      group: node.domain ?? 'Other',
145      size: Math.max(1, node.loc),
146      ...(node.file !== undefined && { file: node.file }),
147    })),
148    files,
149    languages: [...byLanguage.entries()]
150      .sort((a, b) => b[1] - a[1])
151      .slice(0, 3)
152      .map(([name, size]) => ({ name, share: total > 0 ? size / total : 0 })),
153    isPartial: false,
154  }
155}
156
157/**
158 * The unit a path under `legacy/<system>/` belongs to: by the map's own file
159 * where there is a map, else by the file itself or the nearest directory that is a unit.
160 */
161export function unitOfPath(
162  estate: EstateModel,
163  topology: Topology | null,
164  rel: string,
165): EstateUnit | null {
166  const wanted = norm(rel)
167
168  if (estate.source === 'map' && topology !== null) {
169    const node = nodeOfFile(topology, wanted)
170
171    return node === null ? null : (estate.units.find(unit => unit.id === node.id) ?? null)
172  }
173
174  const exact = estate.units.find(unit => unit.file === wanted)
175
176  if (exact !== undefined) {
177    return exact
178  }
179
180  let best: EstateUnit | null = null
181  let bestLength = -1
182
183  for (const unit of estate.units) {
184    const dir = unit.dir
185
186    if (dir === undefined) {
187      continue
188    }
189
190    const isInside = dir === '' || wanted === dir || wanted.startsWith(`${dir}/`)
191
192    if (isInside && dir.length > bestLength) {
193      best = unit
194      bestLength = dir.length
195    }
196  }
197
198  return best
199}
200
201/** The names a transformed directory may be matched to a unit by: the id, the name, and the last path segment. */
202export function keysOfUnit(unit: EstateUnit): string[] {
203  const own = unit.dir !== undefined && unit.dir !== '' ? baseName(unit.dir) : unit.file !== undefined ? stemOf(unit.file) : ''
204
205  return [unit.id, unit.name, own].filter(key => key !== '')
206}
207
208/** The group a unit's directory sits in: its parent directory, or the root's own label. */
209export const groupOfDir = (dir: string, rootLabel: string): string => {
210  const parent = dirName(dir)
211
212  return parent === '' ? rootLabel : parent
213}
214
hooks/reader/fs.ts 80 lines
1/**
2 * The file access the reader needs, narrow enough that `$.fs` and a test's
3 * in-memory tree both satisfy it. Paths are relative to the session's
4 * working directory or absolute, as `$.fs` takes them.
5 */
6export type ReaderFs = {
7  read: (path: string) => Promise<string>
8  /** An entry as it stands: a symbolic link is `other` (with `isLink`), whatever it leads to. `stat` says what that is. */
9  list: (path: string) => Promise<{ name: string; kind: 'file' | 'dir' | 'other'; size: number; isLink?: boolean }[]>
10  exists: (path: string) => Promise<boolean>
11  stat: (path: string) => Promise<{ kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number }>
12}
13
14/** `read`, or null when the file is missing, unreadable or over the size cap. */
15export async function readOrNull(fs: ReaderFs, path: string): Promise<string | null> {
16  try {
17    // `exists` never rejects; asking first keeps a missing optional file out of the error log.
18    if (!(await fs.exists(path))) {
19      return null
20    }
21
22    return await fs.read(path)
23  } catch {
24    return null
25  }
26}
27
28/** `list`, or an empty list when the directory is missing. */
29export async function listOrEmpty(
30  fs: ReaderFs,
31  path: string,
32): Promise<{ name: string; kind: 'file' | 'dir' | 'other'; size: number; isLink?: boolean }[]> {
33  try {
34    if (!(await fs.exists(path))) {
35      return []
36    }
37
38    return await fs.list(path)
39  } catch {
40    return []
41  }
42}
43
44/** `stat`'s mtime, or null when the path is missing. */
45export async function mtimeOrNull(fs: ReaderFs, path: string): Promise<number | null> {
46  try {
47    if (!(await fs.exists(path))) {
48      return null
49    }
50
51    return (await fs.stat(path)).mtimeMs
52  } catch {
53    return null
54  }
55}
56
57/**
58 * The directories under `path`, by name. A symbolic link to a directory counts: the engine lists a link as `other`,
59 * and a legacy tree is often a link to where the code really lives.
60 */
61export async function dirNamesOf(fs: ReaderFs, path: string): Promise<string[]> {
62  const names: string[] = []
63
64  for (const entry of await listOrEmpty(fs, path)) {
65    if (entry.kind === 'dir') {
66      names.push(entry.name)
67    } else if (entry.kind === 'other') {
68      try {
69        if ((await fs.stat(`${path}/${entry.name}`)).kind === 'dir') {
70          names.push(entry.name)
71        }
72      } catch {
73        // a link that leads nowhere is not a system
74      }
75    }
76  }
77
78  return names
79}
80
hooks/reader/modernized.ts 410 lines
1import { join } from '../paths'
2import { linesOf } from '../text'
3import { listOrEmpty, readOrNull, type ReaderFs } from './fs'
4
5/**
6 * The state of each transformed module, read from `modernized/<system>/`:
7 * what exists on disk, what the test reports say, what the notes record.
8 * No inference: a fact that is not on disk is absent.
9 */
10
11export type TestTotals = {
12  tests: number
13  failures: number
14  errors: number
15  skipped: number
16  /** How many report files were read. */
17  reports: number
18}
19
20export type ModuleState =
21  | 'scaffolded'
22  | 'tests-written'
23  | 'tests-red'
24  /** An uplift's tests fail and there is no baseline row to say whether the source runtime failed them too. */
25  | 'tests-failing'
26  | 'tests-green'
27  | 'reviewed'
28  | 'ported'
29  | 'switched'
30
31export type ModernizedModule = {
32  /** The directory's name under `modernized/<system>/`. */
33  dir: string
34  /** Path relative to the working directory. */
35  path: string
36  hasMain: boolean
37  hasTests: boolean
38  hasNotes: boolean
39  /** The date in the notes' "Architecture review" section; absent when none. */
40  reviewDate?: string
41  hasReviewSection: boolean
42  /** The notes say "ported, not switched". */
43  isPortedNotSwitched: boolean
44  /** The notes say the route was switched. */
45  isSwitched: boolean
46  /** Open follow-ups the notes list (bullets under a Follow-ups heading). */
47  followUps: number
48  tests: TestTotals | null
49  state: ModuleState
50  /** Newest mtime among the notes and test reports; 0 when unknown. */
51  mtimeMs: number
52}
53
54/** States a unit of work counts as finished in: the review is done, or the module is ported or switched. */
55const DONE_STATES: ReadonlySet<ModuleState> = new Set(['reviewed', 'ported', 'switched'])
56
57/**
58 * Whether a module's work is finished. Green tests with notes written count: the notes are where the plugin's
59 * review step lists what the critic found, and how they headline it varies, so a missing headline is not unfinished work.
60 */
61export const isDone = (module: Pick<ModernizedModule, 'state' | 'hasNotes'>): boolean =>
62  DONE_STATES.has(module.state) || (module.state === 'tests-green' && module.hasNotes)
63
64const EMPTY: TestTotals = { tests: 0, failures: 0, errors: 0, skipped: 0, reports: 0 }
65
66/** Totals of one Visual Studio test result file (`.trx`), the report `dotnet test --logger trx` leaves. */
67export function totalsOfTrx(xml: string): TestTotals | null {
68  const counters = /<Counters\b[^>]*>/.exec(xml)?.[0]
69
70  if (counters === undefined || !/\btotal="\d+"/.test(counters)) {
71    return null
72  }
73
74  return {
75    tests: attr(counters, 'total'),
76    failures: attr(counters, 'failed'),
77    errors: attr(counters, 'error') + attr(counters, 'timeout') + attr(counters, 'aborted'),
78    skipped: attr(counters, 'notExecuted') + attr(counters, 'inconclusive'),
79    reports: 1,
80  }
81}
82
83const attr = (tag: string, name: string): number => {
84  const match = new RegExp(`\\b${name}="(\\d+)"`).exec(tag)
85
86  return match?.[1] !== undefined ? Number(match[1]) : 0
87}
88
89/** Totals of one JUnit-style XML report (`<testsuite ...>` / `<testsuites ...>`). */
90export function totalsOfJunitXml(xml: string): TestTotals | null {
91  const suites = /<testsuites\b[^>]*>/.exec(xml)?.[0]
92
93  if (suites !== undefined && /\btests="\d+"/.test(suites)) {
94    return {
95      tests: attr(suites, 'tests'),
96      failures: attr(suites, 'failures'),
97      errors: attr(suites, 'errors'),
98      skipped: attr(suites, 'skipped') + attr(suites, 'disabled'),
99      reports: 1,
100    }
101  }
102
103  const all = [...xml.matchAll(/<testsuite\b[^>]*>/g)].map(match => match[0])
104
105  if (all.length === 0) {
106    return null
107  }
108
109  return all.reduce<TestTotals>(
110    (sum, tag) => ({
111      tests: sum.tests + attr(tag, 'tests'),
112      failures: sum.failures + attr(tag, 'failures'),
113      errors: sum.errors + attr(tag, 'errors'),
114      skipped: sum.skipped + attr(tag, 'skipped') + attr(tag, 'disabled'),
115      reports: 1,
116    }),
117    { ...EMPTY, reports: 1 },
118  )
119}
120
121const add = (a: TestTotals, b: TestTotals): TestTotals => ({
122  tests: a.tests + b.tests,
123  failures: a.failures + b.failures,
124  errors: a.errors + b.errors,
125  skipped: a.skipped + b.skipped,
126  reports: a.reports + b.reports,
127})
128
129/** Where test runners leave JUnit XML, relative to a module's directory. */
130const REPORT_DIRS = [
131  'target/surefire-reports',
132  'target/failsafe-reports',
133  'build/test-results/test',
134  'build/test-results',
135  'build/logs',
136  'test-results',
137  'test-reports',
138  'reports/junit',
139  'TestResults',
140]
141
142const REPORT_FILES = ['junit.xml', 'report.xml', 'test-report.xml', 'pytest.xml']
143
144/** When a file last changed; 0 when it cannot be said. */
145async function changedAt(fs: ReaderFs, path: string): Promise<number> {
146  try {
147    return (await fs.stat(path)).mtimeMs
148  } catch {
149    return 0
150  }
151}
152
153/**
154 * The test totals a directory's report files hold: a custom JSON report, JUnit XML, or a `.trx`, and when the newest of
155 * the files read last changed (what a verdict about the module is compared with).
156 */
157export async function readTestsAt(fs: ReaderFs, dir: string, withTime = true): Promise<{ totals: TestTotals; atMs: number } | null> {
158  const stamp = (path: string) => (withTime ? changedAt(fs, path) : Promise.resolve(0))
159  const customPath = join(dir, '.modernize/test-report.json')
160  const custom = await readOrNull(fs, customPath)
161
162  if (custom !== null) {
163    try {
164      const raw = JSON.parse(custom) as Record<string, unknown>
165      const num = (key: string) => (typeof raw[key] === 'number' ? (raw[key] as number) : 0)
166
167      return {
168        totals: {
169          tests: num('tests'),
170          failures: num('failures'),
171          errors: num('errors'),
172          skipped: num('skipped'),
173          reports: 1,
174        },
175        atMs: await stamp(customPath),
176      }
177    } catch {
178      // fall through to the XML reports
179    }
180  }
181
182  let total: TestTotals | null = null
183  let atMs = 0
184
185  for (const sub of REPORT_DIRS) {
186    const entries = await listOrEmpty(fs, join(dir, sub))
187
188    for (const entry of entries) {
189      const isTrx = entry.name.endsWith('.trx')
190
191      if (entry.kind !== 'file' || !(entry.name.endsWith('.xml') || isTrx) || entry.size > 3_500_000) {
192        continue
193      }
194
195      const xml = await readOrNull(fs, join(dir, sub, entry.name))
196      const totals = xml === null ? null : isTrx ? totalsOfTrx(xml) : totalsOfJunitXml(xml)
197
198      if (totals !== null) {
199        total = total === null ? totals : add(total, totals)
200        atMs = Math.max(atMs, await stamp(join(dir, sub, entry.name)))
201      }
202    }
203
204    if (total !== null) {
205      return { totals: total, atMs }
206    }
207  }
208
209  for (const name of REPORT_FILES) {
210    const xml = await readOrNull(fs, join(dir, name))
211    const totals = xml !== null ? totalsOfJunitXml(xml) : null
212
213    if (totals !== null) {
214      return { totals, atMs: await stamp(join(dir, name)) }
215    }
216  }
217
218  return null
219}
220
221/** The test totals a directory's report files hold, without asking when each file changed. */
222export async function readTests(fs: ReaderFs, dir: string): Promise<TestTotals | null> {
223  return (await readTestsAt(fs, dir, false))?.totals ?? null
224}
225
226const TEST_DIRS = ['src/test', 'tests', 'test', 'spec', '__tests__']
227const MAIN_DIRS = ['src/main', 'src', 'app', 'lib']
228
229async function anyDir(fs: ReaderFs, dir: string, names: readonly string[]): Promise<boolean> {
230  for (const name of names) {
231    if ((await listOrEmpty(fs, join(dir, name))).length > 0) {
232      return true
233    }
234  }
235
236  return false
237}
238
239/** What the notes record, read by heading and phrase. */
240export function readNotes(notes: string): {
241  hasReviewSection: boolean
242  reviewDate?: string
243  isPortedNotSwitched: boolean
244  isSwitched: boolean
245  followUps: number
246} {
247  const lines = linesOf(notes)
248  // The plugin's review step spawns the architecture critic and lists what it found in the notes; how the notes
249  // headline that varies, so any heading that says review or critic counts, and so does naming the critic.
250  const named = lines.findIndex(line => /^#{1,4}\s+.*\b(?:review|critic)/i.test(line))
251  const start = named >= 0 ? named : -1
252  let reviewDate: string | undefined
253
254  if (start >= 0) {
255    const level = /^(#+)/.exec(lines[start] ?? '')?.[1]?.length ?? 2
256
257    const end = lines.findIndex(
258      (line, index) => index > start && new RegExp(`^#{1,${level}}\\s`).test(line),
259    )
260
261    const section = lines.slice(start, end < 0 ? undefined : end).join('\n')
262
263    reviewDate = /\b(\d{4}-\d{2}-\d{2})\b/.exec(section)?.[1]
264  }
265
266  const followStart = lines.findIndex(line => /^#{1,4}\s+.*follow-?ups?/i.test(line))
267  let followUps = 0
268
269  if (followStart >= 0) {
270    for (const line of lines.slice(followStart + 1)) {
271      if (/^#{1,4}\s/.test(line)) {
272        break
273      }
274
275      if (/^\s*[-*]\s+(?!\[[xX]\])/.test(line) || /^\s*\d+\.\s+/.test(line)) {
276        followUps += 1
277      }
278    }
279  }
280
281  const isPortedNotSwitched = /ported,?\s+not\s+switched/i.test(notes)
282
283  return {
284    hasReviewSection: start >= 0 || /architecture[- ]critic/i.test(notes),
285    ...(reviewDate !== undefined && { reviewDate }),
286    isPortedNotSwitched,
287    isSwitched:
288      !isPortedNotSwitched &&
289      /\b(?:route|traffic)\b[^\n]{0,40}\bswitched\b|\bswitched\b[^\n]{0,20}\b\d{4}-\d{2}-\d{2}\b/i.test(
290        notes,
291      ),
292    followUps,
293  }
294}
295
296/** The state a module's facts add up to, furthest first. */
297export function stateOf(module: Omit<ModernizedModule, 'state'>): ModuleState {
298  const tests = module.tests
299  const isRed = tests !== null && tests.failures + tests.errors > 0
300  const isGreen = tests !== null && tests.tests > 0 && !isRed
301
302  if (module.isSwitched && isGreen && module.hasReviewSection) {
303    return 'switched'
304  }
305
306  if (module.isPortedNotSwitched && isGreen) {
307    return 'ported'
308  }
309
310  if (isGreen && module.hasReviewSection) {
311    return 'reviewed'
312  }
313
314  if (isRed) {
315    return 'tests-red'
316  }
317
318  if (isGreen) {
319    return 'tests-green'
320  }
321
322  if (module.hasTests) {
323    return 'tests-written'
324  }
325
326  return 'scaffolded'
327}
328
329/**
330 * The state of one unit of an uplift's working copy: worse than its baseline, matching or exceeding it
331 * (every test that passed on the source runtime still passes, none is missing), tested but not comparable
332 * to a baseline row, changed and not yet tested, or not touched at all.
333 */
334export function stateOfUplift(
335  tests: TestTotals | null,
336  baseline: { pass: number; fail: number; error: number; skip: number } | null,
337  isChanged: boolean,
338): ModuleState | 'untouched' {
339  if (tests !== null && tests.tests > 0) {
340    const bad = tests.failures + tests.errors
341
342    // With no baseline row there is nothing to be worse than: the failures are said, not judged.
343    if (baseline === null && bad > 0) {
344      return 'tests-failing'
345    }
346
347    if (baseline !== null && bad > baseline.fail + baseline.error) {
348      return 'tests-red'
349    }
350
351    const passed = tests.tests - bad - tests.skipped
352    const isCovered = baseline !== null && tests.tests >= baseline.pass + baseline.fail + baseline.error + baseline.skip && passed >= baseline.pass
353
354    return isCovered ? 'reviewed' : 'tests-green'
355  }
356
357  return isChanged ? 'scaffolded' : 'untouched'
358}
359
360/** Reads every code directory under `modernized/<system>/`. */
361export async function readModernized(
362  fs: ReaderFs,
363  system: string,
364): Promise<ModernizedModule[]> {
365  return readModernizedIn(fs, join('modernized', system))
366}
367
368/** Reads every code directory directly under `root`: a transformed system's modules, or a reimagined system's services. */
369export async function readModernizedIn(
370  fs: ReaderFs,
371  root: string,
372  observed: ReadonlyMap<string, TestTotals> = new Map(),
373): Promise<ModernizedModule[]> {
374  const entries = await listOrEmpty(fs, root)
375  const out: ModernizedModule[] = []
376
377  for (const entry of entries) {
378    if (entry.kind !== 'dir' || entry.name.startsWith('.')) {
379      continue
380    }
381
382    const path = join(root, entry.name)
383    const notes = await readOrNull(fs, join(path, 'TRANSFORMATION_NOTES.md'))
384    const read = notes !== null ? readNotes(notes) : null
385    const reported = await readTestsAt(fs, path)
386    const tests = reported?.totals ?? observed.get(path) ?? null
387    // The newest of the notes and the test reports: what a verdict about the module is checked against.
388    const mtimeMs = Math.max(notes !== null ? await changedAt(fs, join(path, 'TRANSFORMATION_NOTES.md')) : 0, reported?.atMs ?? 0)
389
390    const facts = {
391      dir: entry.name,
392      path,
393      hasMain: await anyDir(fs, path, MAIN_DIRS),
394      hasTests: await anyDir(fs, path, TEST_DIRS),
395      hasNotes: notes !== null,
396      hasReviewSection: read?.hasReviewSection ?? false,
397      ...(read?.reviewDate !== undefined && { reviewDate: read.reviewDate }),
398      isPortedNotSwitched: read?.isPortedNotSwitched ?? false,
399      isSwitched: read?.isSwitched ?? false,
400      followUps: read?.followUps ?? 0,
401      tests,
402      mtimeMs,
403    }
404
405    out.push({ ...facts, state: stateOf(facts) })
406  }
407
408  return out
409}
410
hooks/reader/progress.ts 986 lines
1import { join } from '../paths'
2import { isSystemName, isToken, plain } from '../text'
3import { parseBrief, type Brief, type Phase } from './brief'
4import { discoverEstate, sourceSizes } from './discover'
5import { estateOfTopology, keysOfUnit, type EstateModel, type EstateUnit } from './estate-model'
6import { dirNamesOf, mtimeOrNull, readOrNull, type ReaderFs } from './fs'
7import { isDone, readModernizedIn, type ModernizedModule, type TestTotals } from './modernized'
8import { needsReview, parseRules, type RuleSet } from './rules'
9import { pickTrack, TRACK_LABELS, type TrackKey } from './tracks'
10import { parseTopology, type Topology } from './topology'
11import { parseVerification, proofOfModule, type ProofState, type Verification } from './verification'
12import { parseLedger } from '../review/ledger'
13import {
14  baselineRowOf,
15  changedUnitsOf,
16  parseBaseline,
17  parseCatalog,
18  changedPathsOf,
19  readUpliftUnits,
20  type Baseline,
21  type Catalog,
22} from './uplift'
23
24/**
25 * One reading of a system's modernization state, from the artifacts on disk
26 * alone. Everything here is a fact of a file that exists; nothing is inferred
27 * by a model, and what cannot be read is absent.
28 */
29
30export type StageKey =
31  | 'preflight'
32  | 'assess'
33  | 'map'
34  | 'rules'
35  | 'brief'
36  | 'transform'
37  | 'harden'
38  | 'deltas'
39  | 'baseline'
40  | 'pilot'
41  | 'migrate'
42  | 'compare'
43  | 'verify'
44  | 'spec'
45  | 'design'
46  | 'scaffold'
47  | 'tests'
48
49export type Stage = {
50  key: StageKey
51  label: string
52  isDone: boolean
53  mtimeMs: number | null
54  /** One short qualifier (`signed`, `319 rules`), when there is one. */
55  detail?: string
56}
57
58export type ReviewVerdict = 'confirmed' | 'wrong' | 'discuss'
59
60export type ReviewLedger = Record<
61  string,
62  { verdict: ReviewVerdict; at: string; title?: string; /** The reviewer's own words, when they gave any. */ note?: string }
63>
64
65export type NextStep = {
66  /** The slash command to run, or what to do by hand. */
67  text: string
68  isByHand: boolean
69  reason: string
70  /** A step the pane can do itself: `sign` opens the sign-off dialog. */
71  action?: 'sign'
72}
73
74/** What an uplift has left on disk, beyond the modules it changed. */
75export type UpliftFacts = {
76  catalog: Catalog | null
77  baseline: Baseline | null
78  hasPlaybook: boolean
79  hasNotes: boolean
80  hasCopy: boolean
81  /** Whether the working copy's changes could be read (a version-control status), or only its test reports. */
82  isChangeKnown: boolean
83}
84
85export type Snapshot = {
86  system: string
87  systems: string[]
88  /** Which way the plugin's commands are working on the system. */
89  track: TrackKey
90  /** True once the system has any artifact under `analysis/`: modernization is under way, not only possible. */
91  hasAnalysis: boolean
92  stages: Stage[]
93  /** The units the estate map draws: the map's modules, or what the legacy tree holds. */
94  estate: EstateModel | null
95  topology: Topology | null
96  rules: RuleSet | null
97  brief: Brief | null
98  /** The units of work under way: transformed modules, uplifted modules, or reimagined services. */
99  modules: ModernizedModule[]
100  /** Estate unit id to its unit of work, where one matches. */
101  byNode: Map<string, ModernizedModule>
102  /** Units of work that match no unit of the estate. */
103  extras: ModernizedModule[]
104  uplift: UpliftFacts | null
105  findings: { exists: boolean; isScan: boolean }
106  /** The proof pack's verdicts, when the verify command has run. */
107  verification: Verification | null
108  /**
109   * Where each built module stands with the proof, by its folder name lower-cased. An uplift is one piece, so it has one
110   * entry, under the name of its working copy (`<system>-uplifted`).
111   */
112  proofs: Map<string, ProofState>
113  reviews: ReviewLedger
114  /** Size of the units finished over the estate's total; null without an estate, or where units of work are not the estate's own. */
115  percent: number | null
116  totals: { modules: number; done: number; loc: number; locDone: number }
117  attention: string[]
118  next: NextStep | null
119  readAtMs: number
120}
121
122export const STAGE_LABELS: Record<StageKey, string> = {
123  preflight: 'preflight',
124  assess: 'assess',
125  map: 'map',
126  rules: 'rules',
127  brief: 'brief',
128  transform: 'transform',
129  harden: 'harden',
130  deltas: 'deltas',
131  baseline: 'baseline',
132  pilot: 'pilot',
133  migrate: 'migrate',
134  compare: 'compare',
135  verify: 'verify',
136  spec: 'spec',
137  design: 'design',
138  scaffold: 'scaffold',
139  tests: 'tests',
140}
141
142const STAGE_FILES = {
143  preflight: 'PREFLIGHT.md',
144  assess: 'ASSESSMENT.md',
145  map: 'topology.json',
146  rules: 'BUSINESS_RULES.md',
147  brief: 'MODERNIZATION_BRIEF.md',
148  transform: 'TRANSFORMATION_NOTES.md',
149  harden: 'SECURITY_FINDINGS.md',
150  verify: 'VERIFICATION.json',
151  deltas: 'DELTA_CATALOG.md',
152  baseline: 'BASELINE.md',
153  pilot: 'PLAYBOOK.md',
154  spec: 'AI_NATIVE_SPEC.md',
155  design: 'REIMAGINED_ARCHITECTURE.md',
156} as const
157
158export const REVIEWS_FILE = 'RULE_REVIEWS.json'
159
160/** Parsed files kept by path and mtime, so an unchanged file is parsed once. */
161export type ReaderCache = Map<string, { mtimeMs: number; value: unknown }>
162
163async function cached<T>(
164  fs: ReaderFs,
165  cache: ReaderCache,
166  path: string,
167  parse: (text: string) => T,
168): Promise<{ value: T | null; mtimeMs: number | null }> {
169  const mtimeMs = await mtimeOrNull(fs, path)
170
171  if (mtimeMs === null) {
172    cache.delete(path)
173
174    return { value: null, mtimeMs: null }
175  }
176
177  const hit = cache.get(path)
178
179  if (hit !== undefined && hit.mtimeMs === mtimeMs) {
180    return { value: hit.value as T, mtimeMs }
181  }
182
183  const text = await readOrNull(fs, path)
184
185  if (text === null) {
186    return { value: null, mtimeMs }
187  }
188
189  const value = parse(text)
190
191  cache.set(path, { mtimeMs, value })
192
193  return { value, mtimeMs }
194}
195
196/** The systems the workspace holds: each directory under `analysis/` and under the legacy tree. */
197export async function systemsOf(fs: ReaderFs, legacyDir = 'legacy'): Promise<string[]> {
198  const names = new Set<string>()
199
200  for (const root of ['analysis', legacyDir]) {
201    for (const name of await dirNamesOf(fs, root)) {
202      // The names the plugin's workflows accept: anything else could not be run on, and is not put in a command or a note.
203      if (isSystemName(name)) {
204        names.add(name)
205      }
206    }
207  }
208
209  return [...names].sort()
210}
211
212const slugOf = (target: string) =>
213  target
214    .toLowerCase()
215    .replace(/[^a-z0-9]+/g, '-')
216    .replace(/^-+|-+$/g, '')
217
218/** How a transformed directory's name is matched to a unit's names. */
219const keyOf = (name: string) => name.toLowerCase().replace(/::/g, '-').replace(/[^a-z0-9]+/g, '-')
220
221function approvedPhases(brief: Brief): Phase[] {
222  if (!brief.approval.isSigned) {
223    return []
224  }
225
226  const ordered = [...brief.phases].sort((a, b) => a.number - b.number)
227
228  return brief.approval.covers === 'full' ? ordered : ordered.slice(0, 1)
229}
230
231/** Whether one unit of work is finished, by what the track counts as finished. */
232export function isUnitDone(
233  snapshot: Pick<Snapshot, 'track' | 'uplift'>,
234  module: Pick<ModernizedModule, 'state' | 'hasNotes'>,
235): boolean {
236  if (snapshot.track !== 'uplift') {
237    return isDone(module)
238  }
239
240  // An uplift is proven by reproducing the baseline; with no per-module baseline, passing is all there is to compare.
241  const baseline = snapshot.uplift?.baseline
242  const hasRows = (baseline?.rows.size ?? 0) > 0 || (baseline?.headline ?? null) !== null
243
244  return module.state === 'reviewed' || (module.state === 'tests-green' && !hasRows)
245}
246
247/** The command that opens the whole process: the prefix without its verb dash (`/code-modernization:modernize`). */
248export const frontDoorOf = (prefix: string): string => prefix.replace(/-$/, '')
249
250/**
251 * The verify step a built module calls for: none yet, changed since it was verified, or failed. A module that is
252 * proven, or partly proven (which a person decides on), calls for nothing; nor does one with no notes and no verdict.
253 */
254function verifyStepOf(prefix: string, system: string, module: Pick<ModernizedModule, 'dir'>, proof: ProofState | undefined): NextStep | null {
255  if (proof === undefined || proof.state === 'proven' || proof.state === 'partly') {
256    return null
257  }
258
259  const name = plain(module.dir, 60)
260  const text = `${prefix}verify ${system}${isToken(module.dir) ? ` ${module.dir}` : ''}`
261
262  if (proof.state === 'none') {
263    return { text, isByHand: false, reason: `${name} is built but not yet checked against the old code` }
264  }
265
266  if (proof.state === 'changed') {
267    return { text, isByHand: false, reason: `${name} changed after it was verified: check it again` }
268  }
269
270  return { text, isByHand: false, reason: `${name} is NOT PROVEN${proof.reason === '' ? '' : `: ${proof.reason}`}. Fix that, then check again` }
271}
272
273function nextOfTransform(
274  prefix: string,
275  system: string,
276  stages: Stage[],
277  brief: Brief | null,
278  byNode: Map<string, ModernizedModule>,
279  hasAnalysis: boolean,
280  proofs: ReadonlyMap<string, ProofState>,
281): NextStep | null {
282  const done = (key: StageKey) => stages.find(stage => stage.key === key)?.isDone === true
283  const cmd = (name: string, rest = '') => `${prefix}${name} ${system}${rest === '' ? '' : ` ${rest}`}`
284
285  // Nothing has been written for this system yet: the front door asks what the person wants, once, and gives the first step.
286  if (!hasAnalysis) {
287    return { text: `${frontDoorOf(prefix)} ${system}`, isByHand: false, reason: 'start here: it asks what you want done, then gives you the first step' }
288  }
289
290  if (!done('preflight') && !done('assess')) {
291    return { text: cmd('preflight'), isByHand: false, reason: 'first, check the environment and that all the code is there' }
292  }
293
294  if (!done('assess')) {
295    return { text: cmd('assess'), isByHand: false, reason: 'what you have: size, complexity, risks and the recommended approach' }
296  }
297
298  if (!done('map')) {
299    return { text: cmd('map'), isByHand: false, reason: 'how the parts of the code connect: calls, data and business flows' }
300  }
301
302  if (!done('rules')) {
303    return { text: cmd('extract-rules'), isByHand: false, reason: 'what the code does, written as rules a business person can check' }
304  }
305
306  if (!done('brief') || brief === null) {
307    return { text: cmd('brief'), isByHand: false, reason: 'the phased plan you approve before anything is built' }
308  }
309
310  if (!brief.approval.isSigned) {
311    return {
312      text: 'approve the brief',
313      isByHand: true,
314      action: 'sign',
315      reason: 'nothing is built until a person approves the plan: sign it here, or tell Claude which phases you approve',
316    }
317  }
318
319  const target = brief.target !== undefined ? slugOf(brief.target) : ''
320
321  for (const phase of approvedPhases(brief)) {
322    // In the order the phase names them: a module that is built and not yet verified is checked before the next one starts.
323    for (const id of phase.modules) {
324      const module = byNode.get(id)
325
326      if (module === undefined || !isDone(module)) {
327        // A module id is text from the map: it goes in a command only when it is one plain token.
328        const isNamed = isToken(id)
329
330        return {
331          text: cmd('transform', `${isNamed ? id : '<module>'}${target === '' ? '' : ` ${target}`}`),
332          isByHand: !isNamed,
333          reason: `Phase ${phase.number} names ${plain(id, 60)} and it is not reviewed yet`,
334        }
335      }
336
337      const step = verifyStepOf(prefix, system, module, proofs.get(module.dir.toLowerCase()))
338
339      if (step !== null) {
340        return step
341      }
342    }
343
344    if (phase.modules.length === 0) {
345      return {
346        text: `pick Phase ${phase.number}'s first module and run ${prefix}transform ${system} <module>${target === '' ? '' : ` ${target}`}`,
347        isByHand: true,
348        reason: `Phase ${phase.number} names no module the map knows`,
349      }
350    }
351  }
352
353  return {
354    text: cmd('status'),
355    isByHand: false,
356    reason:
357      brief.approval.covers === 'full'
358        ? 'every approved phase is built and checked'
359        : 'Phase 1 is built and checked; its retrospective revises the brief before Phase 2 is approved',
360  }
361}
362
363/** The uplift command takes its two versions from what the person said at the start (`INTENT.md`, the brief), and asks when neither has them. */
364function nextOfUplift(prefix: string, system: string, stages: Stage[], proofs: ReadonlyMap<string, ProofState>): NextStep {
365  const done = (key: StageKey) => stages.find(stage => stage.key === key)?.isDone === true
366  const command = `${prefix}uplift ${system}`
367
368  if (!done('deltas')) {
369    return { text: command, isByHand: false, reason: 'first it lists what the newer version breaks in this code' }
370  }
371
372  if (!done('baseline')) {
373    return { text: command, isByHand: false, reason: 'next it records what the tests do today, to compare against' }
374  }
375
376  if (!done('pilot')) {
377    return { text: command, isByHand: false, reason: 'next it migrates one module first and writes down what it learned' }
378  }
379
380  if (!done('compare')) {
381    return { text: command, isByHand: false, reason: 'next it migrates the rest in batches, each compared with the baseline' }
382  }
383
384  // The whole upgraded copy is one thing to prove, under the name of its working copy.
385  const step = verifyStepOf(prefix, system, { dir: `${system}-uplifted` }, proofs.get(`${system}-uplifted`))
386
387  if (step !== null) {
388    return { ...step, text: `${prefix}verify ${system}` }
389  }
390
391  return { text: `${prefix}status ${system}`, isByHand: false, reason: 'the upgrade is built and checked; status says what is stale' }
392}
393
394function nextOfReimagine(prefix: string, system: string, stages: Stage[], modules: readonly ModernizedModule[], proofs: ReadonlyMap<string, ProofState>): NextStep {
395  const done = (key: StageKey) => stages.find(stage => stage.key === key)?.isDone === true
396  const command = `${prefix}reimagine ${system}`
397
398  if (!done('spec')) {
399    return { text: command, isByHand: false, reason: 'first it writes down what the old system does, from its code' }
400  }
401
402  if (!done('design')) {
403    return { text: command, isByHand: false, reason: 'next it designs the new architecture and checks it against that spec' }
404  }
405
406  if (!done('scaffold')) {
407    return { text: command, isByHand: false, reason: 'next it builds each service of the approved design, with tests' }
408  }
409
410  for (const module of modules) {
411    const step = verifyStepOf(prefix, system, module, proofs.get(module.dir.toLowerCase()))
412
413    if (step !== null) {
414      return step
415    }
416  }
417
418  return { text: `${prefix}status ${system}`, isByHand: false, reason: 'the services are built and checked; status says what is stale' }
419}
420
421export type ReadOptions = {
422  /** The system to read; the first one found when absent or unknown. */
423  system?: string
424  /** What a command is written with, up to its verb (`/code-modernization:modernize-`). */
425  commandPrefix: string
426  nowMs: number
427  /** The read-only tree holding the systems, relative to the workspace; `legacy` when absent. */
428  legacyDir?: string
429  /** Which way to follow the system; `auto` when absent: whichever one's artifacts are newest. */
430  track?: TrackKey | 'auto'
431  /** Units of an uplift's working copy that were written during this session: they count as changed even when the file kept its size. */
432  writtenUnits?: ReadonlySet<string>
433  /** How long a listing of the working copy is reused, in milliseconds; 15 seconds when absent. */
434  changedTtlMs?: number
435  /** Test totals read off commands this session, by module path, for modules that left no report file. */
436  observed?: ReadonlyMap<string, TestTotals>
437}
438
439const newest = (...times: (number | null)[]): number | null => {
440  const known = times.filter((time): time is number => time !== null)
441
442  return known.length === 0 ? null : Math.max(...known)
443}
444
445/** The files of an uplift's working copy and their sizes, reused for a short while so an idle pane does not walk the tree again and again. */
446async function workingSizesOf(
447  fs: ReaderFs,
448  cache: ReaderCache,
449  root: string,
450  nowMs: number,
451  ttlMs: number,
452): Promise<Map<string, number> | null> {
453  const key = `changed:${root}`
454  const hit = cache.get(key)
455
456  if (hit !== undefined && nowMs - hit.mtimeMs < ttlMs) {
457    return hit.value as Map<string, number> | null
458  }
459
460  const value = await sourceSizes(fs, root)
461
462  cache.set(key, { mtimeMs: nowMs, value })
463
464  return value
465}
466
467/** The estate to draw: the map's modules, or what the legacy tree holds. Read once per system, then kept. */
468async function estateOf(
469  fs: ReaderFs,
470  cache: ReaderCache,
471  track: TrackKey,
472  topology: Topology | null,
473  legacyRoot: string,
474  system: string,
475): Promise<EstateModel | null> {
476  if (topology !== null && track !== 'uplift') {
477    return estateOfTopology(topology, 0)
478  }
479
480  const key = `estate:${legacyRoot}`
481  const at = (await mtimeOrNull(fs, legacyRoot)) ?? 0
482  const hit = cache.get(key)
483
484  if (hit !== undefined && hit.mtimeMs === at) {
485    return hit.value as EstateModel | null
486  }
487
488  const found = await discoverEstate(fs, legacyRoot, system)
489
490  cache.set(key, { mtimeMs: at, value: found })
491
492  return found ?? (topology !== null ? estateOfTopology(topology, 0) : null)
493}
494
495/** Reads one system's state. Null when the workspace holds no system. */
496export async function readSnapshot(
497  fs: ReaderFs,
498  cache: ReaderCache,
499  options: ReadOptions,
500): Promise<Snapshot | null> {
501  const legacyDir = options.legacyDir ?? 'legacy'
502  const systems = await systemsOf(fs, legacyDir)
503
504  const system =
505    options.system !== undefined && systems.includes(options.system)
506      ? options.system
507      : systems[0]
508
509  if (system === undefined) {
510    return null
511  }
512
513  const dir = join('analysis', system)
514  const legacyRoot = join(legacyDir, system)
515  const transformRoot = join('modernized', system)
516  const upliftRoot = join('modernized', `${system}-uplifted`)
517  const reimagineRoot = join('modernized', `${system}-reimagined`)
518  const observed = options.observed ?? new Map<string, TestTotals>()
519
520  const [topo, rules, catalog, baseline, verificationRead, reviewsRaw, findingsHead, marks] = await Promise.all([
521    cached(fs, cache, join(dir, STAGE_FILES.map), parseTopology),
522    cached(fs, cache, join(dir, STAGE_FILES.rules), parseRules),
523    cached(fs, cache, join(dir, STAGE_FILES.deltas), parseCatalog),
524    cached(fs, cache, join(dir, STAGE_FILES.baseline), parseBaseline),
525    cached(fs, cache, join(dir, STAGE_FILES.verify), parseVerification),
526    readOrNull(fs, join(dir, REVIEWS_FILE)),
527    readOrNull(fs, join(dir, STAGE_FILES.harden)),
528    Promise.all(
529      [
530        mtimeOrNull(fs, dir),
531        mtimeOrNull(fs, join(dir, STAGE_FILES.preflight)),
532        mtimeOrNull(fs, join(dir, STAGE_FILES.assess)),
533        mtimeOrNull(fs, join(dir, STAGE_FILES.pilot)),
534        mtimeOrNull(fs, join(upliftRoot, 'UPLIFT_NOTES.md')),
535        mtimeOrNull(fs, upliftRoot),
536        mtimeOrNull(fs, join(dir, STAGE_FILES.spec)),
537        mtimeOrNull(fs, join(dir, STAGE_FILES.design)),
538        mtimeOrNull(fs, reimagineRoot),
539        mtimeOrNull(fs, transformRoot),
540      ],
541    ),
542  ])
543
544  const [analysisAt, preflightAt, assessAt, playbookAt, upliftNotesAt, upliftCopyAt, specAt, designAt, reimagineDirAt, transformDirAt] = marks
545
546  const topology = topo.value
547  const moduleIds = topology?.modules.map(node => node.id) ?? []
548
549  const brief = await cached(fs, cache, join(dir, STAGE_FILES.brief), text =>
550    parseBrief(text, moduleIds),
551  )
552
553  // A brief parsed before the map existed named no modules: parse it again.
554  if (
555    brief.value !== null &&
556    moduleIds.length > 0 &&
557    brief.value.phases.length > 0 &&
558    brief.value.phases.every(phase => phase.modules.length === 0)
559  ) {
560    const text = await readOrNull(fs, join(dir, STAGE_FILES.brief))
561
562    if (text !== null) {
563      brief.value = parseBrief(text, moduleIds)
564      cache.set(join(dir, STAGE_FILES.brief), { mtimeMs: brief.mtimeMs ?? 0, value: brief.value })
565    }
566  }
567
568  const reviews = parseLedger(reviewsRaw)
569
570  const isScan = findingsHead !== null && /generated-by:\s*modernize-harden/.test(findingsHead.split('\n')[0] ?? '')
571
572  const transformed = await readModernizedIn(fs, transformRoot, observed)
573
574  const track = pickTrack(options.track ?? 'auto', {
575    transformAt: newest(transformDirAt, ...transformed.map(module => module.mtimeMs)),
576    // A baseline says nothing of the track: a rewrite records one too, for the legacy behavior it must keep.
577    upliftAt: newest(catalog.mtimeMs, playbookAt, upliftNotesAt, upliftCopyAt),
578    reimagineAt: newest(specAt, designAt, reimagineDirAt),
579  })
580
581  const estate = await estateOf(fs, cache, track, topology, legacyRoot, system)
582
583  let modules: ModernizedModule[] = []
584  const byNode = new Map<string, ModernizedModule>()
585  const extras: ModernizedModule[] = []
586  let uplift: UpliftFacts | null = null
587
588  if (track === 'uplift') {
589    const hasCopy = upliftCopyAt !== null
590    const legacySizes = estate?.isPartial === false ? estate.fileSizes : undefined
591    const workingSizes = hasCopy && legacySizes !== undefined ? await workingSizesOf(fs, cache, upliftRoot, options.nowMs, options.changedTtlMs ?? 15_000) : null
592    const changedPaths = legacySizes !== undefined && workingSizes !== null ? changedPathsOf(legacySizes, workingSizes) : null
593    const changed = estate !== null && changedPaths !== null ? changedUnitsOf(estate, changedPaths) : new Set<string>()
594
595    for (const id of options.writtenUnits ?? []) {
596      changed.add(id)
597    }
598
599    const pairs =
600      estate !== null && hasCopy
601        ? await readUpliftUnits(fs, upliftRoot, estate, changed, baseline.value, observed)
602        : []
603
604    modules = pairs.map(pair => pair.module)
605
606    for (const pair of pairs) {
607      byNode.set(pair.unit.id, pair.module)
608    }
609
610    uplift = {
611      catalog: catalog.value,
612      baseline: baseline.value,
613      hasPlaybook: playbookAt !== null,
614      hasNotes: upliftNotesAt !== null,
615      hasCopy,
616      isChangeKnown: changedPaths !== null,
617    }
618  } else {
619    modules = track === 'reimagine' ? await readModernizedIn(fs, reimagineRoot, observed) : transformed
620
621    if (track === 'transform') {
622      const byKey = new Map<string, EstateUnit>()
623
624      for (const unit of estate?.units ?? []) {
625        for (const key of keysOfUnit(unit)) {
626          byKey.set(keyOf(key), unit)
627        }
628      }
629
630      for (const module of modules) {
631        const unit = byKey.get(keyOf(module.dir))
632
633        if (unit !== undefined) {
634          byNode.set(unit.id, module)
635        } else {
636          extras.push(module)
637        }
638      }
639    } else {
640      extras.push(...modules)
641    }
642  }
643
644  const snapshotFacts = { track, uplift }
645  const done = modules.filter(module => isUnitDone(snapshotFacts, module))
646
647  // Where each built module stands with the proof. An uplift is one piece: the whole working copy has the one verdict.
648  const verification = verificationRead.value
649  const proofs = new Map<string, ProofState>()
650
651  if (track === 'uplift') {
652    const name = `${system}-uplifted`
653    const proof = proofOfModule(verification, 'uplift', name, upliftNotesAt ?? 0, upliftNotesAt !== null)
654
655    if (proof !== null) {
656      proofs.set(name.toLowerCase(), proof)
657    }
658  } else {
659    for (const module of modules) {
660      const proof = proofOfModule(verification, track, module.dir, module.mtimeMs, module.hasNotes)
661
662      if (proof !== null) {
663        proofs.set(module.dir.toLowerCase(), proof)
664      }
665    }
666  }
667
668  const proofList = [...proofs.values()]
669  const isProofDone = (proof: ProofState) => proof.state === 'proven' || proof.state === 'partly'
670
671  const verifyStage: Stage[] =
672    proofList.length === 0 && track !== 'uplift' && modules.length === 0
673      ? []
674      : [
675          {
676            key: 'verify',
677            label: STAGE_LABELS.verify,
678            isDone: proofList.length > 0 && proofList.every(isProofDone),
679            mtimeMs: null,
680            ...(proofList.length > 0 && {
681              detail:
682                track === 'uplift'
683                  ? (proofList[0]?.verdict ?? 'not verified yet').toLowerCase()
684                  : `${proofList.filter(proof => proof.state === 'proven').length} of ${proofList.length} proven`,
685            }),
686          },
687        ]
688
689  const green = modules.filter(
690    module => module.state === 'tests-green' || module.state === 'reviewed' || isUnitDone(snapshotFacts, module),
691  )
692
693  const passing = green.reduce(
694    (sum, module) => sum + Math.max(0, (module.tests?.tests ?? 0) - (module.tests?.failures ?? 0) - (module.tests?.errors ?? 0)),
695    0,
696  )
697
698  const withFindings: Stage[] =
699    findingsHead !== null || track === 'transform'
700      ? [
701          {
702            key: 'harden',
703            label: STAGE_LABELS.harden,
704            // Any findings file counts. A harden marker on its first line says the command wrote it, and older or
705            // newer versions of the command may or may not stamp one, so its absence is not a reason to doubt the file.
706            isDone: findingsHead !== null,
707            mtimeMs: null,
708          },
709        ]
710      : []
711
712  const stages: Stage[] = [
713    ...(track === 'transform'
714      ? [
715          { key: 'preflight' as const, label: STAGE_LABELS.preflight, isDone: preflightAt !== null, mtimeMs: preflightAt },
716          { key: 'assess' as const, label: STAGE_LABELS.assess, isDone: assessAt !== null, mtimeMs: assessAt },
717          {
718            key: 'map' as const,
719            label: STAGE_LABELS.map,
720            isDone: topology !== null,
721            mtimeMs: topo.mtimeMs,
722            ...(topology !== null && { detail: `${topology.modules.length} modules` }),
723          },
724          {
725            key: 'rules' as const,
726            label: STAGE_LABELS.rules,
727            isDone: rules.value !== null && rules.value.rules.length > 0,
728            mtimeMs: rules.mtimeMs,
729            ...(rules.value !== null && { detail: `${rules.value.rules.length} rules` }),
730          },
731          {
732            key: 'brief' as const,
733            label: STAGE_LABELS.brief,
734            isDone: brief.value !== null,
735            mtimeMs: brief.mtimeMs,
736            ...(brief.value !== null && {
737              detail: brief.value.approval.isSigned ? 'approved' : 'unapproved',
738            }),
739          },
740          {
741            // Transform writes under modernized/, not analysis/: this stage is done once a module's tests are green.
742            key: 'transform' as const,
743            label: STAGE_LABELS.transform,
744            isDone: green.length > 0,
745            mtimeMs: null,
746            ...(modules.length > 0 && {
747              detail: green.length === 0 ? 'in progress' : passing > 0 ? `${passing} green` : `${green.length} module${green.length === 1 ? '' : 's'}`,
748            }),
749          },
750          ...verifyStage,
751        ]
752      : track === 'uplift'
753        ? [
754            { key: 'preflight' as const, label: STAGE_LABELS.preflight, isDone: preflightAt !== null, mtimeMs: preflightAt },
755            {
756              key: 'deltas' as const,
757              label: STAGE_LABELS.deltas,
758              isDone: catalog.mtimeMs !== null,
759              mtimeMs: catalog.mtimeMs,
760              ...(catalog.value !== null && { detail: `${catalog.value.count} deltas` }),
761            },
762            {
763              key: 'baseline' as const,
764              label: STAGE_LABELS.baseline,
765              isDone: baseline.mtimeMs !== null,
766              mtimeMs: baseline.mtimeMs,
767              ...(baseline.value !== null && {
768                detail: baseline.value.isTargetOnly
769                  ? 'target-only'
770                  : baseline.value.results !== null
771                    ? `${baseline.value.results.toLocaleString('en-US')} tests`
772                    : undefined,
773              }),
774            },
775            { key: 'pilot' as const, label: STAGE_LABELS.pilot, isDone: playbookAt !== null, mtimeMs: playbookAt },
776            {
777              key: 'migrate' as const,
778              label: STAGE_LABELS.migrate,
779              isDone: modules.length > 0 && playbookAt !== null,
780              mtimeMs: null,
781              ...(modules.length > 0 && { detail: `${modules.length} module${modules.length === 1 ? '' : 's'}` }),
782            },
783            {
784              key: 'compare' as const,
785              label: STAGE_LABELS.compare,
786              isDone: upliftNotesAt !== null,
787              mtimeMs: upliftNotesAt,
788              ...(modules.length > 0 && { detail: `${done.length}/${modules.length} match` }),
789            },
790            ...verifyStage,
791          ]
792        : [
793            { key: 'preflight' as const, label: STAGE_LABELS.preflight, isDone: preflightAt !== null, mtimeMs: preflightAt },
794            { key: 'spec' as const, label: STAGE_LABELS.spec, isDone: specAt !== null, mtimeMs: specAt },
795            { key: 'design' as const, label: STAGE_LABELS.design, isDone: designAt !== null, mtimeMs: designAt },
796            {
797              key: 'scaffold' as const,
798              label: STAGE_LABELS.scaffold,
799              isDone: modules.length > 0,
800              mtimeMs: null,
801              ...(modules.length > 0 && { detail: `${modules.length} service${modules.length === 1 ? '' : 's'}` }),
802            },
803            {
804              key: 'tests' as const,
805              label: STAGE_LABELS.tests,
806              isDone: modules.length > 0 && green.length === modules.length,
807              mtimeMs: null,
808              ...(modules.length > 0 && { detail: `${green.length}/${modules.length} green` }),
809            },
810            ...verifyStage,
811          ]),
812    ...withFindings,
813  ]
814
815  const loc = (estate?.units ?? []).reduce((sum, unit) => sum + Math.max(1, unit.size), 0)
816
817  const locDone = (estate?.units ?? []).reduce((sum, unit) => {
818    const module = byNode.get(unit.id)
819
820    return module !== undefined && isUnitDone(snapshotFacts, module) ? sum + Math.max(1, unit.size) : sum
821  }, 0)
822
823  const attention: string[] = []
824
825  for (const module of modules) {
826    const row = track === 'uplift' ? baselineRowOf(baseline.value, module.dir) : null
827
828    if ((module.state === 'tests-red' || module.state === 'tests-failing') && module.tests !== null) {
829      const bad = module.tests.failures + module.tests.errors
830
831      attention.push(
832        track === 'uplift'
833          ? row === null
834            ? `${module.dir}: ${bad} tests failing, and the baseline has no row for it to compare with`
835            : `${module.dir}: ${bad} failing, the baseline had ${row.fail + row.error}`
836          : `${module.dir}: ${bad} of ${module.tests.tests} tests red`,
837      )
838    }
839
840    if (module.tests !== null && module.tests.reports > 0 && module.tests.tests === 0) {
841      attention.push(`${module.dir}: test reports exist but 0 cases executed`)
842    }
843
844    if (track === 'transform' && module.state === 'tests-green' && module.hasNotes && !module.hasReviewSection) {
845      attention.push(`${module.dir}: tests green; the notes do not show the architecture review`)
846    }
847  }
848
849  // A check that failed or could not be completed says why, in the check's own first reason. PARTLY PROVEN lets the work go on: a person decides.
850  for (const [name, proof] of proofs) {
851    if (proof.state === 'not' || proof.state === 'partly') {
852      attention.push(`${track === 'uplift' ? 'the upgrade' : (modules.find(module => module.dir.toLowerCase() === name)?.dir ?? name)}: ${proof.verdict ?? ''}${proof.reason === '' ? '' : `: ${proof.reason}`}`)
853    }
854  }
855
856  for (const extra of extras) {
857    if (track === 'transform' && extra.hasMain && (!extra.hasNotes || !extra.hasTests)) {
858      attention.push(
859        `${extra.dir}: shared code without ${[!extra.hasTests && 'tests', !extra.hasNotes && 'notes'].filter(Boolean).join(' or ')}; an unfinished transform`,
860      )
861    }
862
863    if (track === 'reimagine' && extra.hasMain && !extra.hasTests) {
864      attention.push(`${extra.dir}: scaffolded with no acceptance tests`)
865    }
866  }
867
868  if (track === 'uplift' && uplift !== null) {
869    if (uplift.baseline?.isTargetOnly === true) {
870      attention.push('the baseline is target-only: the source runtime could not run, so nothing is compared')
871    }
872
873    if (modules.length > 0 && !uplift.hasPlaybook) {
874      attention.push(`${modules.length} module${modules.length === 1 ? '' : 's'} touched before a playbook was written: pilot one first`)
875    }
876
877    if (modules.length > 0 && uplift.baseline === null) {
878      attention.push('modules touched before a baseline was recorded: nothing to compare them with')
879    }
880  }
881
882  if (
883    brief.mtimeMs !== null &&
884    rules.mtimeMs !== null &&
885    brief.mtimeMs < rules.mtimeMs
886  ) {
887    attention.push('the brief is older than the rule set it was planned from')
888  }
889
890  if (rules.value !== null) {
891    const pending = rules.value.rules.filter(
892      rule => rule.priority === 'P0' && needsReview(rule) && reviews[rule.id] === undefined,
893    ).length
894
895    const wrong = Object.values(reviews).filter(entry => entry.verdict === 'wrong').length
896
897    // A P0 rule a reviewer sent to discussion stops the build commands until it is settled.
898    const discussing = rules.value.rules.filter(rule => rule.priority === 'P0' && reviews[rule.id]?.verdict === 'discuss').length
899
900    if (discussing > 0) {
901      attention.push(`${discussing} high-priority rule${discussing === 1 ? '' : 's'} under discussion: the build waits`)
902    }
903
904    if (wrong > 0) {
905      attention.push(`${wrong} rule${wrong === 1 ? '' : 's'} marked wrong by a reviewer: no test is built on ${wrong === 1 ? 'it' : 'them'}`)
906    }
907
908    if (pending > 0) {
909      attention.push(`${pending} high-priority rule${pending === 1 ? '' : 's'} still need${pending === 1 ? 's' : ''} a person's review`)
910    }
911  }
912
913  const next: NextStep | null =
914    track === 'uplift'
915      ? nextOfUplift(options.commandPrefix, system, stages, proofs)
916      : track === 'reimagine'
917        ? nextOfReimagine(options.commandPrefix, system, stages, modules, proofs)
918        : nextOfTransform(options.commandPrefix, system, stages, brief.value, byNode, analysisAt !== null, proofs)
919
920  return {
921    system,
922    systems,
923    track,
924    hasAnalysis: analysisAt !== null,
925    stages,
926    estate,
927    topology,
928    rules: rules.value,
929    brief: brief.value,
930    modules,
931    byNode,
932    extras,
933    uplift,
934    findings: { exists: findingsHead !== null, isScan },
935    verification,
936    proofs,
937    reviews,
938    percent: track === 'reimagine' || loc === 0 ? null : locDone / loc,
939    totals: {
940      modules: estate?.units.length ?? 0,
941      done: [...byNode.values()].filter(module => isUnitDone(snapshotFacts, module)).length,
942      loc,
943      locDone,
944    },
945    attention,
946    next,
947    readAtMs: options.nowMs,
948  }
949}
950
951/** One line for the status bar and the prompt's hidden context. */
952export function oneLineOf(snapshot: Snapshot): string {
953  // A rewrite's five analysis steps are what "analysis 5/5" counts: its build, its proof and its scan are not among them.
954  const countable = snapshot.stages.filter(stage => stage.key !== 'harden' && stage.key !== 'transform' && !(snapshot.track === 'transform' && stage.key === 'verify'))
955  const done = countable.filter(stage => stage.isDone).length
956
957  const parts =
958    snapshot.track === 'transform'
959      ? [`${snapshot.system}: analysis ${done}/5`]
960      : [`${snapshot.system}: ${TRACK_LABELS[snapshot.track]} ${done}/${countable.length}`]
961
962  if (snapshot.brief !== null) {
963    parts.push(snapshot.brief.approval.isSigned ? 'brief approved' : 'brief waiting for approval')
964  }
965
966  if (snapshot.totals.modules > 0 && snapshot.modules.length > 0) {
967    parts.push(
968      snapshot.track === 'reimagine'
969        ? `${snapshot.modules.length} service${snapshot.modules.length === 1 ? '' : 's'} built`
970        : `${snapshot.totals.done}/${snapshot.totals.modules} modules ${snapshot.track === 'uplift' ? 'match the baseline' : 'reviewed'}`,
971    )
972  }
973
974  const proven = [...snapshot.proofs.values()].filter(proof => proof.state === 'proven').length
975
976  if (proven > 0) {
977    parts.push(snapshot.track === 'uplift' ? 'proven to behave the same' : `${proven} proven`)
978  }
979
980  if (snapshot.attention.length > 0) {
981    parts.push(`${snapshot.attention.length} need${snapshot.attention.length === 1 ? 's' : ''} attention`)
982  }
983
984  return parts.join(' · ')
985}
986
hooks/reader/topology.ts 244 lines
1import { baseName, norm } from '../paths'
2
3/**
4 * `analysis/<system>/topology.json`, read into the few shapes the live pane uses.
5 *
6 * The file is written by an agent, so every field is treated as optional and
7 * of unknown shape: anything that does not fit is dropped, never thrown on.
8 */
9
10export type TopoNode = {
11  id: string
12  name: string
13  /** `module`, `datastore`, `screen`, or whatever the map stage wrote. */
14  kind: string
15  /** Lines of code; 0 when the map gave none. */
16  loc: number
17  /** Source file relative to `legacy/<system>/`, when the node is one file. */
18  file?: string
19  /** The name of the domain (first ancestor of kind `domain`) it sits under. */
20  domain?: string
21  /** The language the map recorded for it, as written (`cobol`, `java`, `php`). */
22  language?: string
23}
24
25export type TopoEdge = { source: string; target: string; kind: string }
26
27export type TopoFlow = {
28  name: string
29  persona?: string
30  steps: { label: string; nodes: string[] }[]
31}
32
33export type Topology = {
34  system: string
35  nodes: TopoNode[]
36  /** Nodes that are code (`kind: module`, or any leaf with a file and loc). */
37  modules: TopoNode[]
38  domains: { name: string; modules: TopoNode[] }[]
39  edges: TopoEdge[]
40  entryPoints: Set<string>
41  deadEnds: Set<string>
42  flows: TopoFlow[]
43  byId: Map<string, TopoNode>
44  /** Lower-cased base name of a node's file, to the nodes that have it. */
45  byFileBase: Map<string, TopoNode[]>
46}
47
48const str = (value: unknown): string | undefined =>
49  typeof value === 'string' && value !== '' ? value : undefined
50
51const isRecord = (value: unknown): value is Record<string, unknown> =>
52  typeof value === 'object' && value !== null && !Array.isArray(value)
53
54const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
55
56function walk(
57  raw: unknown,
58  domain: string | undefined,
59  out: TopoNode[],
60  depth: number,
61): void {
62  if (!isRecord(raw) || depth > 12) {
63    return
64  }
65
66  const id = str(raw.id) ?? str(raw.name)
67
68  if (id === undefined) {
69    return
70  }
71
72  const kind = str(raw.kind) ?? 'node'
73  const name = str(raw.name) ?? id
74  const children = list(raw.children)
75  const here = kind === 'domain' ? name : domain
76  const loc = typeof raw.loc === 'number' && raw.loc > 0 ? raw.loc : 0
77  const file = str(raw.file)
78  const language = str(raw.language)
79
80  if (children.length === 0 || kind === 'module') {
81    out.push({
82      id,
83      name,
84      kind,
85      loc,
86      ...(file !== undefined && { file: norm(file) }),
87      ...(here !== undefined && { domain: here }),
88      ...(language !== undefined && { language }),
89    })
90  }
91
92  for (const child of children) {
93    walk(child, here, out, depth + 1)
94  }
95}
96
97/** Parses topology.json; null when the text is not a usable topology. */
98export function parseTopology(text: string): Topology | null {
99  let raw: unknown
100
101  try {
102    raw = JSON.parse(text)
103  } catch {
104    return null
105  }
106
107  if (!isRecord(raw)) {
108    return null
109  }
110
111  const nodes: TopoNode[] = []
112
113  if (isRecord(raw.root)) {
114    walk(raw.root, undefined, nodes, 0)
115  }
116
117  for (const flat of list(raw.nodes)) {
118    walk(flat, undefined, nodes, 0)
119  }
120
121  if (nodes.length === 0) {
122    return null
123  }
124
125  const byId = new Map<string, TopoNode>()
126
127  for (const node of nodes) {
128    if (!byId.has(node.id)) {
129      byId.set(node.id, node)
130    }
131  }
132
133  const unique = [...byId.values()]
134
135  const modules = unique.filter(
136    node =>
137      node.kind === 'module' ||
138      (node.file !== undefined && node.loc > 0 && node.kind !== 'datastore'),
139  )
140
141  const byDomain = new Map<string, TopoNode[]>()
142
143  for (const node of modules) {
144    const key = node.domain ?? 'Other'
145    const bucket = byDomain.get(key) ?? []
146
147    bucket.push(node)
148    byDomain.set(key, bucket)
149  }
150
151  const byFileBase = new Map<string, TopoNode[]>()
152
153  for (const node of unique) {
154    if (node.file === undefined) {
155      continue
156    }
157
158    const key = baseName(node.file).toLowerCase()
159    const bucket = byFileBase.get(key) ?? []
160
161    bucket.push(node)
162    byFileBase.set(key, bucket)
163  }
164
165  const edges: TopoEdge[] = []
166
167  for (const edge of list(raw.edges)) {
168    if (!isRecord(edge)) {
169      continue
170    }
171
172    const source = str(edge.source) ?? str(edge.from)
173    const target = str(edge.target) ?? str(edge.to)
174
175    if (source !== undefined && target !== undefined) {
176      edges.push({ source, target, kind: str(edge.kind) ?? 'uses' })
177    }
178  }
179
180  const flows: TopoFlow[] = []
181
182  for (const flow of list(raw.flows)) {
183    if (!isRecord(flow)) {
184      continue
185    }
186
187    const name = str(flow.name)
188
189    if (name === undefined) {
190      continue
191    }
192
193    const persona = str(flow.persona)
194
195    flows.push({
196      name,
197      ...(persona !== undefined && { persona }),
198      steps: list(flow.steps)
199        .filter(isRecord)
200        .map(step => ({
201          label: str(step.label) ?? '',
202          nodes: list(step.nodes).filter(
203            (node): node is string => typeof node === 'string',
204          ),
205        })),
206    })
207  }
208
209  const ids = (value: unknown) =>
210    new Set(list(value).filter((id): id is string => typeof id === 'string'))
211
212  return {
213    system: str(raw.system) ?? '',
214    nodes: unique,
215    modules,
216    domains: [...byDomain.entries()].map(([name, members]) => ({
217      name,
218      modules: members,
219    })),
220    edges,
221    entryPoints: ids(raw.entryPoints),
222    deadEnds: ids(raw.deadEnds),
223    flows,
224    byId,
225    byFileBase,
226  }
227}
228
229/**
230 * The topology node a legacy file belongs to: by its path relative to
231 * `legacy/<system>/` when the map recorded one, else by base name alone.
232 */
233export function nodeOfFile(topo: Topology, fileRel: string): TopoNode | null {
234  const wanted = norm(fileRel)
235  const candidates = topo.byFileBase.get(baseName(wanted).toLowerCase()) ?? []
236
237  return (
238    candidates.find(node => node.file === wanted) ??
239    candidates.find(node => wanted.endsWith(node.file ?? '\0')) ??
240    candidates[0] ??
241    null
242  )
243}
244
hooks/review/deck.ts 128 lines
1import type { ReviewLedger, ReviewVerdict } from '../reader/progress'
2import { needsReview, type Rule, type RuleSet } from '../reader/rules'
3
4/**
5 * The rule review deck: the rules a person should look at, in the order they
6 * matter, and the ledger of what that person decided. The ledger is a file
7 * of its own beside the rules (`RULE_REVIEWS.json`, with a readable
8 * `RULE_REVIEWS.md`), so the agent-written rules file is never edited.
9 */
10
11export type DeckScope = 'flagged' | 'p0' | 'all'
12
13const weight = (rule: Rule): number =>
14  (rule.priority === 'P0' ? 0 : rule.priority === 'P1' ? 10 : 20) +
15  (rule.sme !== undefined ? 0 : 2) +
16  (rule.defect !== undefined ? 0 : 1) +
17  (rule.confidence !== undefined && rule.confidence !== 'High' ? 0 : 1)
18
19/** The rules to review under `scope`, optionally only those citing a file or under a domain. */
20export function queueOf(rules: RuleSet, scope: DeckScope, filter = ''): Rule[] {
21  const wanted = filter.trim().toLowerCase()
22
23  return rules.rules
24    .filter(rule => {
25      if (scope === 'flagged' && !(rule.priority === 'P0' && needsReview(rule))) {
26        return false
27      }
28
29      if (scope === 'p0' && rule.priority !== 'P0') {
30        return false
31      }
32
33      if (wanted === '') {
34        return true
35      }
36
37      return (
38        rule.id.toLowerCase() === wanted ||
39        (rule.domain ?? '').toLowerCase().includes(wanted) ||
40        rule.title.toLowerCase().includes(wanted) ||
41        rule.citations.some(citation => citation.base.includes(wanted)) ||
42        // What the rule says and what is suspected of it: "truncat" finds the rounding defect whatever its title.
43        [rule.statement, rule.then, rule.defect].some(text => (text ?? '').toLowerCase().includes(wanted))
44      )
45    })
46    .map((rule, index) => ({ rule, index }))
47    .sort((a, b) => weight(a.rule) - weight(b.rule) || a.index - b.index)
48    .map(entry => entry.rule)
49}
50
51/** The first card at or after `from` that has no verdict yet; `from` itself when all have. */
52export function nextUnreviewed(queue: readonly Rule[], ledger: ReviewLedger, from: number): number {
53  for (let step = 0; step < queue.length; step += 1) {
54    const index = (from + step) % queue.length
55    const rule = queue[index]
56
57    if (rule !== undefined && ledger[rule.id] === undefined) {
58      return index
59    }
60  }
61
62  return Math.min(Math.max(0, from), Math.max(0, queue.length - 1))
63}
64
65/** `ledger` with `rule` decided. */
66export function decide(
67  ledger: ReviewLedger,
68  rule: Rule,
69  verdict: ReviewVerdict,
70  atIso: string,
71): ReviewLedger {
72  // The reviewer's own words on the rule stay through a new verdict: the deck decides, it does not rewrite what was said.
73  const note = ledger[rule.id]?.note
74
75  return { ...ledger, [rule.id]: { verdict, at: atIso, title: rule.title, ...(note !== undefined && { note }) } }
76}
77
78/** `ledger` with `rule`'s verdict taken back. */
79export function undecide(ledger: ReviewLedger, ruleId: string): ReviewLedger {
80  const { [ruleId]: _gone, ...rest } = ledger
81
82  return rest
83}
84
85export const tallyOf = (queue: readonly Rule[], ledger: ReviewLedger) => ({
86  total: queue.length,
87  confirmed: queue.filter(rule => ledger[rule.id]?.verdict === 'confirmed').length,
88  wrong: queue.filter(rule => ledger[rule.id]?.verdict === 'wrong').length,
89  discuss: queue.filter(rule => ledger[rule.id]?.verdict === 'discuss').length,
90  open: queue.filter(rule => ledger[rule.id] === undefined).length,
91})
92
93/** The ledger as `RULE_REVIEWS.json` stores it. */
94export function ledgerJson(system: string, ledger: ReviewLedger): string {
95  return `${JSON.stringify({ system, version: 1, reviews: ledger }, null, 2)}\n`
96}
97
98const VERDICT_WORDS: Record<ReviewVerdict, string> = {
99  confirmed: 'Confirmed',
100  wrong: 'Wrong',
101  discuss: 'Needs discussion',
102}
103
104/** One table cell: one line, no pipe that would end it. */
105const cell = (text: string | undefined): string => (text ?? '').replace(/\s+/g, ' ').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').trim()
106
107/** The ledger as a page a person or a model reads: `RULE_REVIEWS.md`. */
108export function ledgerMarkdown(system: string, ledger: ReviewLedger): string {
109  const entries = Object.entries(ledger).sort((a, b) => a[0].localeCompare(b[0], 'en', { numeric: true }))
110  const count = (verdict: ReviewVerdict) => entries.filter(([, entry]) => entry.verdict === verdict).length
111
112  return [
113    `# Rule reviews: ${system}`,
114    '',
115    `A person's verdict on individual business rules in \`BUSINESS_RULES.md\`, recorded from the review deck. ${count('confirmed')} confirmed, ${count('wrong')} wrong, ${count('discuss')} needing discussion.`,
116    '',
117    'A rule marked **Wrong** or **Needs discussion** is not settled: do not build on it until it is resolved. A rule with no row here has not been reviewed.',
118    '',
119    '| Rule | Verdict | When | Title | Note |',
120    '|---|---|---|---|---|',
121    ...entries.map(
122      ([id, entry]) =>
123        `| ${id} | ${VERDICT_WORDS[entry.verdict]} | ${entry.at.slice(0, 10)} | ${cell(entry.title)} | ${cell(entry.note)} |`,
124    ),
125    '',
126  ].join('\n')
127}
128
hooks/review/ledger.ts 79 lines
1import { plain } from '../text'
2import type { ReviewLedger, ReviewVerdict } from '../reader/progress'
3
4/**
5 * `RULE_REVIEWS.json`, read without trusting it. Two writers keep it: this deck, and the `modernize-review` command
6 * for people who work without the pane. Each entry is `{ verdict, at, title?, note? }`, the note being the reviewer's
7 * own words. The file sits in a workspace anyone may have edited, and a model may have written it, so every value is
8 * checked: a verdict is one of three exact words, a rule id is one plain token, a title or a note is cut to one short
9 * plain line, and anything else in an entry is dropped.
10 */
11
12const VERDICTS: readonly string[] = ['confirmed', 'wrong', 'discuss']
13
14/** What a note may hold: a reviewer's sentence or two, not a document. */
15export const MAX_NOTE = 500
16const MAX_TITLE = 120
17const MAX_ENTRIES = 5_000
18const RULE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
19const FORBIDDEN_ID = new Set(['__proto__', 'constructor', 'prototype'])
20
21const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
22
23/** The ledger a file's text holds; empty when it is not one. */
24export function parseLedger(text: string | null): ReviewLedger {
25  if (text === null || text.length > 4_000_000) {
26    return {}
27  }
28
29  let raw: unknown
30
31  try {
32    raw = JSON.parse(text)
33  } catch {
34    return {}
35  }
36
37  if (!isRecord(raw) || !isRecord(raw.reviews)) {
38    return {}
39  }
40
41  const ledger: ReviewLedger = {}
42
43  for (const [id, item] of Object.entries(raw.reviews).slice(0, MAX_ENTRIES)) {
44    if (!RULE_ID.test(id) || FORBIDDEN_ID.has(id) || !isRecord(item) || typeof item.verdict !== 'string' || !VERDICTS.includes(item.verdict)) {
45      continue
46    }
47
48    const title = typeof item.title === 'string' ? plain(item.title, MAX_TITLE) : ''
49    const note = typeof item.note === 'string' ? plain(item.note, MAX_NOTE) : ''
50    // A time is kept only when it is one: the page shows its date.
51    const at = typeof item.at === 'string' && /^\d{4}-\d{2}-\d{2}/.test(item.at) ? item.at.slice(0, 40) : ''
52
53    ledger[id] = { verdict: item.verdict as ReviewVerdict, at, ...(title !== '' && { title }), ...(note !== '' && { note }) }
54  }
55
56  return ledger
57}
58
59/**
60 * The file's ledger with this session's decisions on top. `edits` maps a rule id to what the deck set it to, or null when the
61 * deck took its verdict back. What the other writer put in the file meanwhile stays, and a note the deck did not write is
62 * kept on a rule the deck decided again.
63 */
64export function mergeLedger(onDisk: ReviewLedger, edits: ReadonlyMap<string, ReviewLedger[string] | null>): ReviewLedger {
65  const merged: ReviewLedger = { ...onDisk }
66
67  for (const [id, edit] of edits) {
68    if (edit === null) {
69      delete merged[id]
70    } else {
71      const note = edit.note ?? onDisk[id]?.note
72
73      merged[id] = { ...edit, ...(note !== undefined && { note }) }
74    }
75  }
76
77  return merged
78}
79