@lntt/lntt
v0.6.3
Published
The lntt command: a Lunette project's configuration and operator profile, the managed block of AGENTS.md, the checks and reviews of a change, and its CI run on the local machine with the candidate automation
Readme
@lntt/lntt
The lntt command, for the people and the agents of a Lunette project: its
configuration and the operator profile, the managed block of AGENTS.md, the checks and
the review of a change, branch names, the apps run locally, and its CI run on the local
machine with the candidate automation (lntt ci).
The configuration
Reads, validates and edits a Lunette project's configuration: the values skills and
scripts need, in .agents/config.jsonc or .agents/config.yml at the repository
root (one of the two; both is an error). Sections are named after process areas
(recording, board, ci, delivery, cycle, spaces, team, local) and the
tools that read them; schema/config.schema.json says
what each one holds.
Both formats allow comments: a short reason for a value goes next to it. So scripts
never parse the file themselves (jq and JSON.parse reject comments), and writes go
through an editor that keeps them.
lntt path # the file in use
lntt validate # exit 0 when it matches the schema, every value naming another list is in it, and the board has its horizon; warns of a delivery.review.warningRounds not below maxRounds
lntt get recording.logs # a dotted key, as plain JSON (pipe it to jq)
lntt set recording.language '"it"' # a JSON value, comments keptExit codes: 0 ok, 1 configuration error, 2 usage error.
The operator profile
Who is talking to the agent: personal, never tracked, validated by
schema/operator.schema.json (name, handle, role,
language, confidence per topic, accounts, workspace: the preferred viewers in order). Read from, in merge order:
~/.agents/operator.{jsonc,yml}: every project;<main checkout>/.agents/local/operator.{jsonc,yml}: this project, found from any worktree throughgit rev-parse --git-common-dir;- otherwise
~/.agents/projects/<org>/<repo>/operator.{jsonc,yml}: this project, when the main checkout has none (a fresh clone, a cloud session).
The project profile wins key by key. With none at all the speaker is a developer.
lntt operator # the merged profile
lntt operator get language # one key
lntt operator sources # the files it came fromEditors validate the file against the schema through its first line:
{ "$schema": "<path or URL of config.schema.json>", … }# yaml-language-server: $schema=<path or URL of config.schema.json>Commit messages
commitlintConfig() builds a commitlint configuration from delivery.commits
(language, scopes, line length, trailers exempt from it), with rules whose messages say
what to write instead and name the commit-message skill:
// commitlint.config.mjs
import { commitlintConfig } from '@lntt/lntt'
export default commitlintConfig()AGENTS.md and the shared skills
lntt sync writes the managed block of AGENTS.md from
agents/block.md, and a stub of each shared skill the package
ships (skills/: commit-message, coordinate, decisions,
github-board, mutation-check, plan-round, retrospective, review, roadmap,
run-local) into
.agents/skills/ and .claude/skills/; commit them. A stub holds the
skill's frontmatter, for discovery, and the instruction to load its guide:
pnpm exec lntt guide decisions # the guide, from the installed package
pnpm exec lntt guide # the skills it hasIt also writes into .claude/settings.json, keeping every other key, comment and hook
of the file (an edited entry is reformatted): the directory marketplace this package ships
(.claude-plugin/marketplace.json, path
./node_modules/@lntt/lntt), the lunette plugin enabled from it
(plugin/: the lntt review band, the issue titles on screen), and a
SessionStart hook that installs the dependencies when node_modules/.bin/lntt is
missing: with pnpm, or npm ci in a project with only a package-lock.json, within ten
minutes. The hook never fails a session: when the install cannot run it says, once,
what to run. Beside it go two lines of lntt hook: lntt hook format after Write and
Edit, which passes the written file to the format commands, and lntt hook typecheck
at Stop, in the background, which wakes the agent with a failure only when it differs
from the last one. Their commands are local.hooks.format and local.hooks.typecheck
(one command or a list), else the package.json script of that name, else none:
"local": { "hooks": { "typecheck": ["tsc --noEmit", "uv run mypy ."] } }So the skills, the plugin and the lntt they call are one version, the project's
lockfile: a bump of @lntt/lntt updates the guides and the plugin with no other step.
lntt check agents-md fails when the block differs from its source; lntt check
skills when a stub is missing or edited, or names a skill the package no longer has;
lntt check plugin when an entry of .claude/settings.json is missing, disabled or
points elsewhere. Either way, lntt sync runs on the branch of the bump and its diff
goes in the same pull request (agent layer).
Checks (lntt check)
What is checked without judgement, one area per review lens, so a reviewer reads the result instead of redoing it by eye.
lntt check # every area: what CI runs
lntt check agents-md # the managed block of AGENTS.md
lntt check skills # the stubs of the shared skills
lntt check plugin # the plugin and the hooks in .claude/settings.json
lntt check decisions # the decision logs of recording
lntt check decisions --base main # … and what the diff from main touches
lntt check decisions board-refs docs/product/pricing.md # the board references of those files
lntt check decisions numbers docs/adr/review-agents.md # every line carrying a figure
lntt check tests # levels, focus, rules (touched needs --base)
lntt check tests touched --base main # the changed lines and the tests that run them
lntt check tests mutate src/a.ts:12 --text='a > b' --to='a < b' # does a test fail?
lntt check cite docs/adr/review-agents.md:12 a.ts:40 # what is on those lines
lntt check --json # one JSON line per resultEvery check reports PASS, FAIL or NOT RUN with its reason: an area or a log the
project has not configured is NOT RUN, never a silent pass. INFO is printed without
passing or failing (the diff inventory, the figures). Exit 1 on a FAIL, 2 on a usage
error. --json prints one line per result: area, check, status, path, line,
message.
lntt check decisions (or decisions structure) reads every log of recording.logs:
- index: no index row without its file, no record left out of the index, a row
labelled with a subject points at that subject's file. A log with no
indexisNOT RUN. - references: every relative link into a log and every path to a record
(
docs/adr/<subject>.md, in a document or in code, also wrapped over two lines) resolves to a file that exists; a record cited by number (ADR-NNNN) is an error. Files that cite the records of the project they ship to, not this one's, are listed inrecording.externalReferenceswith their reason; a glob that matches no file fails. - board-refs: in a log whose
boardReferencesisfalse, no Markdown file under its folder (.mdor.mdx, the index, the frontmatter and subfolders included) cites the board: no bare issue number (#123; an anchor, an entity like'or a path's tail likex.md#3is not one) and no/issues/Nor/pull/Nof anhttp(s)://URL, outside code. Each one fails at itspath:linewith the reference. With files (board-refs <file>..., paths from where it runs, as apre-commithook passes the staged files) it reads those of them that are in such a log. No log withboardReferences: falseisNOT RUN. - residues: no append-only marker in a record (
Superseded by,Supersedes,Amended by,## Update,## Addendum, struck-through text, their Italian forms), outside code spans. - names: the file name is the subject in kebab-case (no number, no date), the title
carries no number, and the status is one the log's
templatelists in its frontmatter (status: proposed # proposed | accepted).
With --base <ref> it adds the inventory of the diff (<ref>...HEAD: each record
added, modified, deleted or renamed, and the other files), checks residues and names on
the records the diff touches only, and prints the figures of those records.
numbers prints the figures alone: every line carrying a percentage, an "N of M" or a
number of two digits or more, the frontmatter, issue numbers and dates apart. Files of a
nested project (a folder with a .agents/config.* of its own, a template) are that
project's check.
lntt check tests reads tests of the configuration (the
record says why and how):
- levels: each test file (
*.test.*,*.spec.*or a level'sfiles) at exactly one level oftests.levels, and a file at a level with aharnessnames one of its identifiers. The pyramid per level, what the diff adds to each and the jsdom tests that stub a Web API areINFO. - focus:
.only(always),.skipand todo (the diff's, or all without--base), a test with no assertion (tests.assertionsadds functions that throw). - rules:
tests.rules, a regular expression per line of the test files, each with itsanchor; a rule withassertedByis cited, not checked. - touched (
--base): per changed source file, the tests that run its changed lines (from thecoveragecommand of their level, an lcov file at{out}/lcov.info) and the lines no test runs; withoutcoverage, the tests that import the file. - mutate
path:line --text=<on that line> --to=<mutated>: applied in a scratch git worktree (the head and the uncommitted files, dependencies shared; the working tree is never written), runs the tests that import the file with theruncommand of their level,repeattimes in a row.PASSis killed (the failing test and its message),FAILsurvived,NOT RUNwhen the text is not once on the line or the tests fail without the mutation. Write--text=...with the=when the text starts with-.
cite reads the working tree: the text of each line, or FAIL when the file or the
line does not exist.
Branch names
lntt branch name --issue 77 # feat/77/branch-names-follow-type: type, issue, slug of the title
lntt branch name --issue 77 --slug worktree-names # --type and --slug override
lntt branch name --type docs --slug version-numbering # docs/no-issue/version-numbering
lntt branch check # exit 1 off the convention, with the expected name
lntt branch check --pre-push # the branches a push creates or updates (git's hook input)
lntt branch rename # fix the name a tool gave the current branch; never failsThe convention is <type>/<issue>/<slug> or <type>/no-issue/<slug>; the types are
delivery.branches.types plus the hotfix prefix, and the base, the candidates and
Dependabot's branches are exempt. name reads the issue with gh (its native type,
else the labels bug and documentation, else feat). rename reads the convention
back from a flattened name (Orca's <user>/<type>-<issue>-<slug>, the agent harness's
worktree-<type>+<issue>+<slug>) or, inside Orca, builds it from the issue linked to the
worktree (orca worktree current); it leaves an existing branch alone.
The pull request body
lntt pr block # the empty retrospective block, from the configuration
lntt check body # FAIL on a missing block, a missing, repeated or empty label
lntt check body --pr 171 # another pull request's body; `-` reads it on stdin
lntt check body template # the template holds the block lntt pr block prints
lntt retro add --label friction "…" # one item under a label of the open candidate's retrospective commentThe block is delivery.pullRequest.retrospective: its heading and its labels, each
written - **<label>:** <value> between <!-- retro:start --> and <!-- retro:end -->,
the markers on lines of their own. A value may run on the lines below its label; an HTML
comment is no value; the configured none is an answer. lntt check body is an area of
lntt check whose input is not in the repository: it reads the current branch's pull
request with gh unless told otherwise, and lntt check alone reports it NOT RUN.
Its template check reads the repository: the template at delivery.pullRequest.template
holds the block lntt pr block prints, so lntt check alone runs it. The retrospective skill fills the block and harvests the
merged pull requests' blocks.
A candidate's block is a comment, not its body: the candidate automation opens every
candidate with a comment holding the empty block, and never rewrites it as it rewrites
the body. lntt retro add --label <label> "<one sentence>" appends one item under that
label, on the open candidate unless --pr <n> names another pull request: the label is
the configured one equal to it ignoring case, or the only one it starts (--label to
is To automate (hook/skill/agent)); an empty label or none takes the item on its
line, a label with a value gets sub-bullets; the markers and the other labels stay.
With no comment holding the block, it writes one. On a candidate, lntt check body
reads that comment instead of the body: the first comment holding the start marker on a
line of its own.
An item whose friction comes from another repository starts with it, as
[upstream:<owner/name>] followed by a space: cycle.retrospective.upstream.repository
for Lunette itself (lntt, a shared skill, the scaffolder, a template),
- **Friction:** [upstream:LunetteOrg/starter] lntt review …, or a library's own
repository, - [upstream:remix-run/react-router] …. Only an item starting
[upstream: is a marker: [src/app] … is plain text. lntt check body fails a marker
whose repository is not owner/name or that is joined to what follows, and a marker with
no sentence after it.
Reports on Lunette itself
lntt dump [--json] [--harness <name>] [--model <id>] [-- <failing command>...]
lntt issue search <words>... # the open issues upstream that match: one JSON line each
lntt issue draft.md # the whole issue, dump included; nothing sent
lntt issue draft.md --send # opened with gh; prints its URL
lntt issue draft.md --comment 7 --send # a comment on an existing issue instead
lntt issue draft.md --repo remix-run/react-router # another repository: printed, never sentlntt dump prints, in Markdown (--json: one object), what a report on Lunette needs:
the lntt version and how it is installed, Node, the package manager and the OS, the
harness and its version (detected from the environment; --harness overrides) and the
model (--model: the agent knows it, the environment does not), the project's skills,
git and agent hooks, plugins, @lntt/* packages and configuration, and, after --, the
failing command, run again, with its exit code and the last 200 lines of its output. It
is redacted before it is printed: the home path becomes ~, the project's git remotes
<remote>, a URL with credentials, an email, a token by its shape (GitHub, Anthropic,
OpenAI, Slack, AWS, npm, a JWT, a bearer), the value of a key that names a secret and the
value of every secret-named environment variable of 8 characters or more <redacted> or
<token>. A configuration that does not validate is reported in the dump (with --json, in its
configuration line and its configError field), printed whole, and lntt dump exits 1;
no configuration file is the defaults, exit 0. The failing command is stopped after 10 minutes.
lntt issue <draft | -> reads a draft (# <title>, then ## What happened,
## Pull requests, ## Proposal) and writes the report's fields, each a ### <field>
heading as GitHub writes a submitted issue form: the project
(--project, default the name of its package.json) with its template and version (the
lunette key, when it is set), the harness, the model and the
versions from the dump (--dump <file> from lntt dump --json, else collected now),
the whole dump folded at the end. Without --send it is a dry run: it prints the issue
and sends nothing, so the operator reads exactly what will be sent; the draft and the
project's name are redacted as the dump is, and a body or a title past GitHub's limits
is a warning there and a refusal with --send. It sends with gh, under the account
gh uses (GH_TOKEN when it is set, else the operator's login), to
cycle.retrospective.upstream.repository; without it, lntt issue (and search) exits
1 naming the key. --repo <owner/name> names another repository: its report is printed
for the operator to file by hand, and --send is refused. When the upstream is the
repository lntt issue runs in (GITHUB_REPOSITORY, else the origin remote; names
compared ignoring case, as GitHub does), there is nothing to send: it says so and exits 0. --harness and --model beside --dump replace the dump's. The
retrospective skill drafts these reports from the items marked with the upstream and calls
lntt issue, never gh.
Starting work on an issue
lntt worktree start --issue 78 # a worktree, its setup, an agent with the brief
lntt worktree start --issue 78 --issue 85 # one each
lntt worktree start --issue 78 --no-agent # the worktree only; the brief is printed
lntt worktree start --issue 78 --host git # force the host
lntt worktree start --issue 78 --prompt '…' # a prompt of your own instead of the brief
lntt worktree start --issue 78 --kind codex --model gpt-6 --effort highPer issue: the branch name from lntt branch name, a worktree on it, the setup
orca.yaml declares, and an agent (--kind: claude by default, codex, opencode, …;
--model and --effort as that agent takes them, an effort it has no flag for is
refused; opencode gets its model through its inline configuration,
OPENCODE_CONFIG_CONTENT, on a standalone server) handed the brief in
agents/brief.md: the issue is the mandate, work only in this
worktree, review and pnpm ci:local green, a draft pull request, then stop (no
merge, no gh pr ready) and return a summary and the friction met. One JSON line per
issue: host, branch, path, and the agent's handle.
The host is the innermost one the session runs in, detected from the environment:
- Herdr (
HERDR_ENV=1, also inside an Orca terminal):herdr worktree createon the branch, the setup run here (Herdr has no hook),herdr agent startin the new pane (model and effort after--),herdr agent promptwith the brief. opencode with a model is typed into the pane instead and prompted through the pane once Herdr detects it. - Orca (
TERM_PROGRAM=Orca): oneorca worktree createwith the issue linked, the setup run by Orca (itslntt branch renameturns<user>/<name>into the convention) and the agent started after it (setupAgentStartupPolicy: wait-for-setup).--modeland--effortgo on the sameworktree createwhen its help lists them (1.4.229 does), which refuses a combination the agent does not take before creating anything; the reply'slaunch.outcome.handleis the agent's handle. On an Orca whoseworktree createtakes no model (1.4.224): the worktree with its issue and Orca's setup skipped, the setup run bylntt, then a supervised worker on it (orca orchestration worker-start, in a Run created when none is bound to the terminal). opencode with a model starts in a terminal of its own instead (Orca refuses an opencode model to its worker), and gets the brief once idle. - Plain git otherwise, Wave and Claude desktop included:
git worktree addunder<main checkout>/.worktrees/, the setup, and the brief printed for the person.
Coordinating a theme
A theme is several issues at once. Its coordinator is a worktree that writes no code and
starts one child per issue (coordinator mode; the
instructions are the shared coordinate skill).
lntt worktree start --coordinator --theme release-0-6-1 --issue 84 --issue 89 --approve 89
lntt worktree start --theme release-0-6-1 --parent current --issue 89 \
--context theme-89.md --size S [--base <branch>] [--workflow]--coordinatorstarts the theme's coordinator on the model ofcoordinator.models.coordinator, withagents/coordinator.md;--approvelists the children it may start without asking.--thememakes a child the theme's:--contextis the child's section of the theme, rendered into its brief;--sizepicks its model fromcoordinator.models.children(--kind,--modeland--effortwin);--parentis the coordinator's worktree for Orca's lineage;--workflowadds the multi-agent section (agents/workflow.md).- With
coordinator.gateatpropose(default), a child not yet approved is proposed to the orchestrator and nothing is created until it answersapprove.
The agents talk through the mailbox, which needs no Orca or Herdr:
lntt ask --to coordinator --theme release-0-6-1 "Which pull request first?" # waits; stdout is the answer
lntt inbox --as coordinator --theme release-0-6-1 [--wait] # unanswered messages, one JSON line each
lntt answer 20261017-101500-which-pull-request "The CLI one."A message is <git common dir>/lntt-messages/to-<role>/<YYYYMMDD-HHMMSS>-<topic>.md, its answer the
same name ending in .answer.md; polled every 20 seconds (LNTT_POLL_SECONDS). A child is
addressed by --issue, a coordinator by --theme. --timeout <s> ends a wait with exit 1 and
leaves the message in place; - reads the text from stdin; LNTT_ROLE, LNTT_THEME and
LNTT_ISSUE default inbox's role and address.
Reviewing a change
lntt review # one round on the current branch; JSON on stdout
lntt review --base <branch> # against another base (a stacked pull request)
lntt review --plan # the range, the lenses and their agents; runs nothing
lntt review reply f2 "line 120 excludes it" # answer the reviewers who found f2
lntt review allow 2 # two rounds more for this branch, past maxRoundsA round, on a clean working tree:
- What to read: the branch from where it left the base the first time, then only
the commits after the last reviewed one (after a rebase, the whole branch again).
The state is
lntt-review.jsonin the worktree's git directory. - Checks: a lens named after an area of
lntt check(decisions,tests) getslntt check <area> --base <first commit read> --jsonin its prompt, and its brief keeps only what needs judgement. - Reviewers, all at once: every agent of every lens in
delivery.review.reviewerswhosepathsthe diff touches.decisions,codeandtestsship their briefs inagents/review/; another lens brings its ownbrief. Each agent runs headless with no context but its prompt (on stdin) and no tool that writes:claude -pwith only Read, Grep and Glob and no MCP server;opencode runon a standalone server with an inline agent that denies every tool and allows read, grep, glob and list back.--planand every round name where the diff goes: the provider of each configured agent. - Citations:
lntt check citereads the line of every finding (the working tree, clean at the head). A finding on a line past the end of its file is refuted there, with no verifier; the others take the line's text to their verifiers. A file that is not there goes to the verifiers: the change may delete it. Mutations: atestsfinding that ends withmutate: `text` => `mutated`is run between the reviewers and the verifiers (lntt check tests mutate, one scratch copy for the round): survived confirms the finding and killed refutes it, the run being the proof, with no verifier asked. - Verifiers, all at once: each blocker and warning (
delivery.review.verify) goes to every verifier but the agents that found it, briefed to refute it (agents/verify.md). All confirm: confirmed. All refute: refuted. Otherwise undecided. Findings still open from the last round go back to them on the new head.
Progress goes to stderr (tokens per agent, round N done) and, as JSON events with the
pid of lntt review and a beat every 15 seconds, to <git dir>/lntt-review.progress
for a band beside the agent. Under Claude Code in Orca each agent shows as a subagent
of the session; agents never inherit the host's hook variables. On macOS caffeinate
keeps the machine awake for the round.
stdout is the round: findings to act on, refuted with their proofs, failed
reviewers and verifiers, cost in USD when the agents report it (claude does). Exit 0
when no blocker or warning is left and nothing failed, 1 otherwise; notes are reported
and do not block, nor are they carried to the next round. From the round after
delivery.review.warningRounds a warning does not block either: the round's blocking
names only blocker, and the warnings left go in the pull request as open items. The
branch keeps a ledger of the findings it closed (fixed, refuted, accepted open) in
<git dir>/lntt-review-ledger.json: each round's reviewers read the entries on the files of
the change and those with no anchor, each verifier those on its finding's file; a
fresh finding that repeats one on a line that did not change goes to closed, not
verified and not blocking. A reply that is neither
finding lines nor no findings counts as a failed reviewer; after a failure the next
round reads the same range again. A reviewer is stopped after
delivery.review.timeoutMinutes and counts as failed; at most
delivery.review.perProvider required agents of one provider run at once, and as many
optional ones in a queue of their own, the whole round (or a reply) stops at delivery.review.roundTimeoutMinutes, an
agent that ignores SIGTERM is killed, and one round or reply runs at a time in a
worktree. An agent marked
optional that fails is reported in skipped and does not fail the round; it is stopped
delivery.review.optionalGraceMinutes after the others have answered. A round that
cannot start prints lntt review: error: …. Agents that are not installed, or
that cannot be made read-only, are refused before any starts.
reply resumes the sessions of the reviewers who found the finding with the author's
answer (agents/reply.md); the finding closes when all withdraw it.
A finding whose reviewer cannot be resumed is refused: the person decides on it.
A branch takes at most delivery.review.maxRounds rounds, one with a failure among
them. The round past the limit does not start: lntt review: limit: … on stderr with the findings still open, the
same findings as JSON on stdout, a limit event in the progress file, exit 3. allow
gives the current branch more rounds, kept in its state; the configuration and the
other branches stay as they are. A run with no new commit is not a round and is never
stopped.
Running the apps locally
local.apps.<name> describes each app: base port, url, commands (with start),
env, signIn (a test and/or a document) and seeded users, never a password.
lntt local app <name> resolves it for the current checkout: the main checkout
uses the base port, each linked worktree a stable slot (base + 10 × slot, kept in
<git common dir>/lunette/ports.json, freed when the worktree goes), with {port}
filled in. The run-local skill starts from it.
Showing a page
lntt viewer detect # where the session runs, the chosen viewer, why
lntt viewer open <url> # open it there; exit 3 + JSON when only the agent canThe choice: the first of the operator's workspace.viewers available in this
session, else the app the session runs in (Orca opens in the browser of the same
worktree, in a new tab when the worktree has none), else the system browser. Claude
desktop (preview_start), Chrome (navigate) and Playwright without its CLI on the
PATH (its MCP, browser_navigate) are tool calls only the agent can make: open
prints the call.
The app the session runs in is read innermost first: Claude desktop by
CLAUDE_CODE_ENTRYPOINT (claude-desktop, claude-desktop-3p, local-agent), then
Herdr (HERDR_ENV=1; its panes also carry the ORCA_* or WAVETERM_* of the
terminal that started its server), then TERM_PROGRAM (Orca, waveterm), then
ORCA_WORKTREE_ID and WAVETERM_*.
Driving a browser
lntt browser [<app>] [--json] # how an agent drives a browser on <app>, in this worktreeIt prints, for the app's URL on this worktree's port, which browser to drive and why,
then one line per operation: open, read (an accessibility snapshot with element
refs), click, type, screenshot (to a file), console (errors), requests and
close, after a setup where the driver needs one. <app> may be omitted when
local.apps has one app; --json prints the same as one object.
The choice: the first of the operator's workspace.browsers usable in this session,
else the app the session runs in (Orca drives the browser of the same worktree,
Claude desktop its built-in browser), else Playwright through npx @playwright/cli,
in a session named after the app and port and writing its files outside the
worktree. Orca and Playwright are shell commands; Chrome and Claude desktop are tool
calls only the agent can make, printed with the call that loads them. Chrome counts
wherever it is listed, since no shell can tell whether a session has its tools: it goes
after the browsers that are detected.
Issue and pull request references
lntt refs '#12' o/r#3 # these references, with their titles
git log -1 --format=%B | lntt refs # every reference of a text on stdin
lntt refs --cached < message.md # only what the cache holds: no gh call per issueA bare #N (or repo#N naming the session's repository) is the session's repository
as gh repo view names it, owner/repo#N is that repository, any other word#N is no
reference; code and link URLs are not read, and after a reference to another
repository a bare #N is left unresolved. Titles come from gh api, six at once, three
seconds at most each, cached for a day in <git dir>/lntt-issue-titles.json with the
session's repository, which holds while the git remotes read the same (the cache keeps
their sha256, never a URL); without the session's repository nothing is looked up.
The output is one JSON object:
repository, mentions (each reference-looking match with its ref, its index and
the after position where a title goes, UTF-16 indices into the text) and refs
(each reference once with its title and url, or a why: unknown, failed,
off, cached). The lunette plugin's hook shows these titles on screen.
A text that starts with - goes after -- (lntt refs -- '-see #12'). Exit codes: 0
with the JSON, also when gh is missing or not logged in (each reference's why is
off); 1 when the text cannot be read or the command fails, one line on stderr; 2 a
usage error, as with no text and a terminal on stdin.
The board (lntt board sync)
lntt board sync # what the Project and the issue types lack, read from GitHub: a dry run
lntt board sync --apply # makes those changes, then reads the board back
lntt board sync --check # exit 1 when the board differs from the configuration: for a hook or a scheduled jobIt brings the GitHub Project of board.github.project and its organization's issue
types to what the board section declares. Through the API, each change keeping the
id of what it changes, so no item or issue loses a value:
- a missing field of
board.fields(single select with its options, date, number, text); - a missing option, and the colors of
board.fields.<key>.colors(an option left out keeps its color, a new one is gray), every existing option passed back with its id; - a field, an option or an issue type whose name differs from the configuration's only in case, renamed;
- a missing issue type of
board.issueTypes, and the descriptionboard.issueTypes.descriptionsgives a type; - the views of
board.github.views: a missing one created, and the name, layout, filter and visible fields of each set as declared (a key a view leaves out is not managed; a view it does not name is left alone).
What the API cannot do, or what is risky, it lists under by hand, in GitHub's UI, each
with the reason, the settings page and the steps: a view's grouping or a board's columns
that differ from groupBy or columns (read from the view, set only in the UI), a
field of another type (the API never changes a type), an iteration field (its iterations
are not in the configuration), an
option or a type the configuration lacks (removing it clears it from every item or
issue: the entry says how many hold it, counted from the Project's items or a search of
the organization's issues). A field of kind: issue is the organization's, not the
Project's, and is left alone. A field renamed other than by case is not recognised: the
command would create the new name beside the old one, so that rename is done in the UI
first. Run twice, the second run has nothing to change.
It writes through gh api graphql, one change at a time, 1.2 s apart (GitHub's
secondary rate limit drops faster writes without a word); the token needs the project
scope, and an organization owner to create or rename an issue type. Exit codes: 0 the
plan, or every change applied and read back, or with --check a board as declared; 1 a
configuration error, a read or a write gh refuses (with the changes applied before
it), a change that does not show on the read-back, or with --check a board that
differs (a change or an entry by hand); 2 a usage error.
CI (lntt ci)
Runs a project's CI on the local machine, and moves pull requests through the
candidate branch. ci:local reads the provider's own configuration: GitHub
Actions workflows parsed with @actions/workflow-parser (conditions evaluated with
@actions/expressions), CircleCI expanded with circleci config process and read
with yaml. The project's ci.local values (cloud-only jobs, fake secrets, actions
mapped to local commands) are read from the configuration above. When to run it and the
secrets policy are the project's CI process description (docs/process/ci.md).
lntt ci local # the jobs a ready pull request into the base triggers
lntt ci local --list # per job: what the cloud runs, what runs locally
lntt ci local --base candidate/3 # the pull request into another base
lntt ci local --event push # a push to the base
lntt ci local --job ci.yml/ci # one job (repeatable)
lntt ci local --strict # fail on anything the cloud runs and the run did not
lntt ci local --container # each job in act / circleci local execute
lntt ci local --no-lock # without waiting for the ci:local locklntt ci candidate # in the candidate workflow: reads the pull_request event
lntt ci candidate --pr 12 --dry-run # from a terminal: what the workflow would do for #12lntt ci proved --job ci --step 'Full CI' # in a push run: is this tree already proved green?A project runs the first as pnpm ci:local; the second is the candidate automation
(delivery.candidate), run by the project's candidate workflow, whose flow is the
delivery process description (docs/process/delivery.md). The candidate logic is
adapted from DiscenTech/pelion's scripts/ci/release-candidate.mjs. Exit codes: 0 green, 1 a red job or a
configuration error, 2 a usage error.
The third answers, in a push run, whether the pushed tree is one a pull request's run
already proved green: the tree of the pull request merged into the branch, the branch's
previous tip an ancestor of its head (so the merge ref it tested had that tree), and a
completed pull request run of the workflow on that head with every --job green and,
with --step, that step green in each: the marker of the level that proves. It only
reads the GitHub API. Exit 0 means proved; anything else, an API error included, means
the caller runs the whole job, so a failure can only cost a full run. How a workflow
uses it is the project's CI process description.
What it runs
- The event. By default a pull request from the current branch into the base,
ready for review: the run the cloud starts once a draft leaves draft. The base
defaults to the branch
origin/HEADpoints at.--basenames another one;--event pushstands in for a push to the base. Nothing is fetched and GitHub is never asked: the base is the local ref (origin/<base>, else<base>), and the report names it. - Which jobs. A workflow runs when its
on:takes the event, with its branch, types and path filters applied to the local diff. A job'sif:is evaluated against the event: event name, draft, base and head refs, the repository and its default branch, the job's env, the fake values. A job withneedsis decided after them, as on GitHub:success()when every need passed,failure()when one failed,always(). A condition that reads what only the cloud knows (another job's outputs, a label, the actor) is not guessed: the job is reported as not run, with what it reads. - The steps.
run:steps run in order on the host, from the working tree, with the env the workflow gives them andCI=true. A step withoutshell:runs asbash -e, without pipefail, as on GitHub; an explicitshell: bashadds pipefail, andshis supported. - Known actions stand for a local check.
actions/checkoutand the cache actions do nothing;pnpm/action-setupchecks that pnpm is on the PATH;actions/setup-nodechecks the host Node againstnode-versionornode-version-file. Any other action fails the run unlessci.local.actionsmaps it, whatever its inputs read. - Failures. After a failed step, only steps that ask for a failure run
(
if: failure(),always(), CircleCI'swhen: on_fail). A job whose condition is false after its needs is skipped; the other jobs go on, so one run lists every red job. - Not run, reported: a step whose env or inputs read an undeclared secret,
matrices, reusable workflows,
services:(not started), CircleCI background steps.
Parallel runs and timeouts
- The lock. A run (not
--list, and not a run refused for a dirty tree) takes the ci:local lock before its first job and gives it back when it ends, on an interrupt too, so runs from parallel worktrees go one at a time instead of starving each other's tests. It is a file in$XDG_CACHE_HOME/lntt(else~/.cache/lntt;$LNTT_CI_LOCK_DIRnames another folder), which must be local to the machine: a holder is judged by its pid.ci.local.locksets its scope:machine(the default), oneci-local.lockfor every run on the machine, of any project;project, one file per repository, shared by its worktrees.--no-lockruns without it. A lock folder that cannot be made or read stops the run with one line,lntt ci: cannot take the ci:local lock (<reason>) — <path>; --no-lock runs without it, exit 1. - Who holds it. The file holds the owner's record: pid, host (shown, not used to
judge it), worktree, the moment it took the lock, and the process group of the step
it is running. A run that finds the lock taken prints, once per holder,
lntt ci: waiting for the ci:local lock held by <worktree> (pid <pid> on <host>, since <HH:MM>, <N>m ago) — <lock file>, looks again every 5 seconds, and says how long it waited once it has the lock. - The maximum hold time. A holder's run stops 120 minutes after it took the lock:
the running step is stopped as on a timeout and fails with the machine's load (past
it between two steps, the next step to start fails so, whatever its kind), the rest
is skipped with
the run passed the lock's maximum hold time of 120 minutes, and the run exits 1. - A stale lock is taken over: its owner is no longer running and neither is the
step it recorded, its record is unreadable for more than 10 seconds, or it has been
held past the maximum hold time and 2 minutes of grace, the holder having failed to
stop, which the waiter reports naming the holder. An owner killed while its step
runs leaves that step's process group behind: the waiter says so,
… which is no longer running, but its step (process group <N>) still is, and waits for it. Removals happen under a short guard file,<lock>.takeover, held by one run at a time: its holder removes the lock only if it is still the record judged stale, so a lock another run has just taken stays. A guard older than 10 seconds was left by a crash and is removed, only while it is still that guard. The owner waits for the guard at most 200 ms to record a step's process group, and 2 seconds to give the lock back; past that, the lock is left to go stale by its dead pid. A lock that cannot be given back is one line on stderr, and the run keeps its exit code. - The timeout. Each step runs in a process group and a session of its own, with no
controlling terminal, as on a CI runner: a step that prompts on
/dev/tty(an ssh passphrase, a credential prompt, sudo) fails. One that outlastsci.local.timeoutMinutes(default 30, at most 1440; in container mode, the whole job) gets SIGTERM to its group, then SIGKILL five seconds later, and fails; one that ended on its own before the signal is judged by its exit code. The following steps go on as after any failure. The report prints next to the step the machine's load, read once the step was signalled,timed out after 30m — load 48.2 / 31.0 / 20.5 on 15 cores, 9 containers running (7 Testcontainers)(the containers when Docker answers within 3 seconds;load unavailablewhen it cannot be read), and the job line names the step. Nothing is retried: a green means every step passed once. - An interrupt (SIGINT, SIGTERM, SIGHUP, SIGQUIT) starts no further step or job,
stops the running step's group the same way, prints
lntt ci: interrupted (<signal>), gives the lock back and exits 128 + the signal's number (130, 143, 129, 131), with no report. - Container mode's limit. Stopping
actorcircleci local execute, on a timeout or an interrupt, does not stop the containers they started: they keep running until they end or are removed by hand (docker ps,docker rm -f).
CircleCI
circleci config process expands .circleci/config.yml (orbs, commands, parameters).
Which workflows run is decided from the raw file's when: against the event's
pipeline values (pipeline.event.name, pipeline.git.branch,
pipeline.event.github.pull_request.base.ref), in expressions and logic maps alike.
The CLI evaluates when: without event values, so it processes a copy with every
when: removed. A pipeline value the local run does not have makes the workflow not
run, reported.
A dynamic configuration (setup: true) is followed to the continuation its chosen
setup workflow names (config-path of path-filtering, configuration_path of
continuation). The continuation's parameters come from the path-filtering mapping,
matched against the files changed since its base-revision. Branch filters apply to
the branch the pipeline builds. A job with a context is reported as not run unless
cloud-only; project environment variables are not visible in the configuration and
are not detected.
Container mode
--container runs each job whole instead of step by step. For GitHub Actions it uses
act, with an event file for the same pull request or push, the fake secrets as -s
and the fake vars as --var; act reads its runner images from .actrc
(-P ubuntu-latest=<image>). For CircleCI it uses circleci local execute on the
processed configuration, with the fake secrets as -e. It checks that the executor
and Docker are on the PATH before starting.
Configuration
"ci": {
"local": {
// Jobs that need the cloud, by key: `<workflow file>/<job>`, a whole
// `<workflow file>`, or `circleci/<job>`.
"cloudOnly": { "release.yml": "publishes to npm with the release's OIDC token" },
// Values a check only needs to be present. Tracked: never a real secret.
"fake": { "secrets": { "CANARY_TOKEN": "not-a-secret" } },
// Actions ci:local does not know: a command instead, or a skip with its reason.
"actions": { "actions/upload-artifact": { "skip": "reports stay in the working tree" } },
// What a run queues behind: every run on the machine, or this repository's (project).
"lock": "machine",
// How long one step may run before it is stopped and fails with the machine's load
// (at most 1440).
"timeoutMinutes": 30
}
}"delivery": {
// Pull requests into main collect on candidate/<N>.
"candidate": { "base": "main", "prefix": "candidate/", "hotfix": { "prefix": "hotfix/", "label": "hotfix" } }
}Requirements
The candidate automation needs gh, authenticated with a token that has contents,
pull requests and issues on the repository.
To move an issue on the board (delivery.candidate.status: In release on the squash
into a candidate, Done on the candidate's merge) it also needs access to the Project:
the token in PROJECTS_GH_TOKEN in the workflow, with the organization permission
Projects, or the login of gh with the project scope in a terminal
(gh auth refresh -s project). Without it the board is left as it is, with a ::notice.
CircleCI projects need the circleci CLI; container mode needs Docker and act or the
circleci CLI.
Requirements
Node ≥ 24. A project installs it as a development dependency, with
@commitlint/config-conventional as a peer when its commitlint configuration uses
commitlintConfig:
pnpm add -D @lntt/lntt @commitlint/config-conventionalThe configuration's $schema can point at the installed schema,
node_modules/@lntt/lntt/schema/config.schema.json. In the starter the package runs
from its sources through the workspace; the published package is compiled to dist/.
