lockwire 0.1.27 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/dist/autofix.d.ts +85 -0
- package/dist/autofix.d.ts.map +1 -0
- package/dist/autofix.js +506 -0
- package/dist/autofix.js.map +1 -0
- package/dist/changed.d.ts +5 -0
- package/dist/changed.d.ts.map +1 -1
- package/dist/changed.js +16 -0
- package/dist/changed.js.map +1 -1
- package/dist/cli.js +47 -3
- package/dist/cli.js.map +1 -1
- package/dist/ledger.d.ts +2 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +15 -11
- package/dist/ledger.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +16 -25
- package/dist/mcp-server.js.map +1 -1
- package/dist/tasks.d.ts +6 -0
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +18 -0
- package/dist/tasks.js.map +1 -1
- package/dist/values.d.ts.map +1 -1
- package/dist/values.js +18 -3
- package/dist/values.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ Or without installing anything, via `npx`:
|
|
|
196
196
|
npx lockwire check
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
**GitHub Action** — merge-time gate:
|
|
199
|
+
**GitHub Action** — merge-time gate (and [`lockwire/autofix`](docs/autofix.md) to fix what it finds):
|
|
200
200
|
|
|
201
201
|
```yaml
|
|
202
202
|
- uses: actions/checkout@v4
|
|
@@ -291,6 +291,17 @@ lockwire resolve k7q2m9xv --note "documented the new scope parameter"
|
|
|
291
291
|
|
|
292
292
|
Every resolution is recorded with who made it and why. See [docs/agents.md](docs/agents.md#the-work-queue-your-agent-fixes-lockwire-checks).
|
|
293
293
|
|
|
294
|
+
To run it unattended, `lockwire autofix` starts Claude Code or Codex on your own key — only when a claim is actually wrong — then re-checks every claim and refuses a run that touched anything but docs. In CI, the Action does the same and opens a pull request:
|
|
295
|
+
|
|
296
|
+
```yaml
|
|
297
|
+
- uses: Reality-Ventures/lockwire/autofix@v0
|
|
298
|
+
with:
|
|
299
|
+
agent: claude-code
|
|
300
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
See [docs/autofix.md](docs/autofix.md).
|
|
304
|
+
|
|
294
305
|
## What lockwire does not do
|
|
295
306
|
|
|
296
307
|
Hashes cannot catch semantic drift — a doc saying "we use Redux" when the code moved to Zustand, where every file path still resolves and no signature changed. Catching that requires reasoning about meaning, not fingerprints; [ClaudeDrift](https://github.com/marky291/claude-drift) does this well as an on-demand, LLM-reasoning layer, and pairs naturally with lockwire's continuous, deterministic one — see [docs/comparison.md](docs/comparison.md). Numbers are the exception: a sentence bound to the value it came from — a JSON key or a LaTeX macro a pipeline writes — is checked against that value at the precision it states, so "the lower bound is 0.18" is flagged when a rerun makes it 0.19, and not when it makes it 0.1791 ([docs/concepts.md](docs/concepts.md#numbers-and-other-values)). P0 supports TypeScript, TSX, JavaScript, and Python source, and values in JSON and LaTeX files; cross-file rename detection, a resolver agent, and semantic-claim extraction are roadmap, not shipped — see [docs/concepts.md](docs/concepts.md#limitations-read-before-relying-on-this-in-production).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type Task } from "./tasks.js";
|
|
2
|
+
import type { LedgerRecord } from "./types.js";
|
|
3
|
+
export type AgentKind = "claude-code" | "codex";
|
|
4
|
+
export interface AutofixOptions {
|
|
5
|
+
/** A built-in agent launcher. */
|
|
6
|
+
agent?: AgentKind;
|
|
7
|
+
/** Or any shell command; it gets LOCKWIRE_PROMPT_FILE, LOCKWIRE_MCP_CONFIG and LOCKWIRE_REPO. */
|
|
8
|
+
agentCommand?: string;
|
|
9
|
+
/** Limit the work to these paths (as `lockwire tasks <paths>`). */
|
|
10
|
+
scope?: string[];
|
|
11
|
+
/** Turn budget for the agent. */
|
|
12
|
+
maxTurns?: number;
|
|
13
|
+
/** Stop the agent after this long. */
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
/** Print what would run, and run nothing. */
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
/** How the agent should start lockwire's MCP server. Default: this lockwire (`node <cli.js> mcp`). */
|
|
18
|
+
lockwireCommand?: {
|
|
19
|
+
command: string;
|
|
20
|
+
args: string[];
|
|
21
|
+
};
|
|
22
|
+
/** Show the agent's output (default true); tests turn it off. */
|
|
23
|
+
showAgentOutput?: boolean;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
}
|
|
26
|
+
export interface AutofixResolution {
|
|
27
|
+
event: LedgerRecord["event"];
|
|
28
|
+
anchor: string;
|
|
29
|
+
doc: string | null;
|
|
30
|
+
target: string;
|
|
31
|
+
claim: string | null;
|
|
32
|
+
note: string | null;
|
|
33
|
+
by: string;
|
|
34
|
+
}
|
|
35
|
+
export interface AutofixReport {
|
|
36
|
+
/**
|
|
37
|
+
* nothing-to-do: no task, no agent started. fixed: every task is gone. partial: some remain.
|
|
38
|
+
* refused: the agent changed files that aren't documentation, or couldn't be run — don't ship it.
|
|
39
|
+
*/
|
|
40
|
+
status: "nothing-to-do" | "fixed" | "partial" | "refused";
|
|
41
|
+
before: Task[];
|
|
42
|
+
after: Task[];
|
|
43
|
+
/** How many of `before` are gone from `after` (new problems the run caused don't offset it). */
|
|
44
|
+
fixed: number;
|
|
45
|
+
/** Why a run was refused, besides out-of-bounds files: the agent committed, edited lockwire's state by hand, ... */
|
|
46
|
+
problems: string[];
|
|
47
|
+
resolutions: AutofixResolution[];
|
|
48
|
+
/** Every file the run changed (repo-relative). */
|
|
49
|
+
changedFiles: string[];
|
|
50
|
+
/** Changed files that are neither docs nor lockwire's own (code, data, config): why a run is refused. */
|
|
51
|
+
outOfBounds: string[];
|
|
52
|
+
agent?: {
|
|
53
|
+
command: string;
|
|
54
|
+
exitCode: number | null;
|
|
55
|
+
error?: string;
|
|
56
|
+
};
|
|
57
|
+
/** A pull-request body: what was fixed, what's left, and anything refused. */
|
|
58
|
+
markdown: string;
|
|
59
|
+
}
|
|
60
|
+
/** The argv that starts a built-in agent on `prompt`. Exported so the exact flags are tested. */
|
|
61
|
+
export declare function agentArgv(agent: AgentKind, prompt: string, files: {
|
|
62
|
+
mcpConfig: string;
|
|
63
|
+
}, opts?: {
|
|
64
|
+
maxTurns?: number;
|
|
65
|
+
bare?: boolean;
|
|
66
|
+
}): {
|
|
67
|
+
command: string;
|
|
68
|
+
args: string[];
|
|
69
|
+
};
|
|
70
|
+
/** Claude Code's `--mcp-config` file: lockwire's MCP server, started the way this lockwire runs. */
|
|
71
|
+
export declare function mcpConfigJson(lockwire: {
|
|
72
|
+
command: string;
|
|
73
|
+
args: string[];
|
|
74
|
+
}): string;
|
|
75
|
+
/** Codex's config.toml: lockwire's MCP server, required, its tools approved without prompting. */
|
|
76
|
+
export declare function codexConfigToml(lockwire: {
|
|
77
|
+
command: string;
|
|
78
|
+
args: string[];
|
|
79
|
+
}): string;
|
|
80
|
+
/** How many of the tasks before a run are gone after it. New problems don't offset fixed ones. */
|
|
81
|
+
export declare function fixedCount(before: readonly Task[], after: readonly Task[]): number;
|
|
82
|
+
export declare function autofix(repoRoot: string, opts?: AutofixOptions): Promise<AutofixReport>;
|
|
83
|
+
/** A pull-request body for an autofix run. */
|
|
84
|
+
export declare function summaryMarkdown(r: AutofixReport): string;
|
|
85
|
+
//# sourceMappingURL=autofix.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"autofix.d.ts","sourceRoot":"","sources":["../src/autofix.ts"],"names":[],"mappings":"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"}
|
package/dist/autofix.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lockwire autofix`: run the agent the user already pays for against lockwire's work queue, then
|
|
3
|
+
* check what it did. lockwire supplies no model and never edits a sentence itself; it decides whether
|
|
4
|
+
* there is anything to do (so an agent is only started when a claim is actually wrong), hands the
|
|
5
|
+
* agent the fix-docs instructions and lockwire's own MCP server, and afterwards re-checks every claim
|
|
6
|
+
* from scratch and refuses a run that changed anything other than documentation.
|
|
7
|
+
*
|
|
8
|
+
* Agents: Claude Code (`claude -p`), Codex (`codex exec`), or any command (`--agent-command`), which
|
|
9
|
+
* gets the prompt and an MCP config through LOCKWIRE_PROMPT_FILE / LOCKWIRE_MCP_CONFIG.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readSync, rmSync, writeFileSync, } from "node:fs";
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { homedir, tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { headCommit, indexEntries } from "./changed.js";
|
|
19
|
+
import { readConfig } from "./config.js";
|
|
20
|
+
import { canonicalJson } from "./hash.js";
|
|
21
|
+
import { ledgerPath, verifiedRecord } from "./ledger.js";
|
|
22
|
+
import { readLockfile } from "./lockfile.js";
|
|
23
|
+
import { scanMarkers } from "./markers.js";
|
|
24
|
+
import { isScannedDoc, resolveInsideRepo, walkFiles } from "./repo.js";
|
|
25
|
+
import { fixDocsPrompt, listTasks } from "./tasks.js";
|
|
26
|
+
/** The argv that starts a built-in agent on `prompt`. Exported so the exact flags are tested. */
|
|
27
|
+
export function agentArgv(agent, prompt, files, opts = {}) {
|
|
28
|
+
if (agent === "claude-code")
|
|
29
|
+
return {
|
|
30
|
+
command: "claude",
|
|
31
|
+
args: [
|
|
32
|
+
"-p",
|
|
33
|
+
prompt,
|
|
34
|
+
// In CI, --bare: don't run the repository's own .claude/settings.json hooks or .mcp.json
|
|
35
|
+
// servers (repository-controlled code) with the API key in the environment. Not locally,
|
|
36
|
+
// where it would also switch off a subscription login.
|
|
37
|
+
...(opts.bare ? ["--bare"] : []),
|
|
38
|
+
"--mcp-config",
|
|
39
|
+
files.mcpConfig,
|
|
40
|
+
"--strict-mcp-config",
|
|
41
|
+
// Read and edit files, and use lockwire's tools; no shell, so the agent can't run repo code.
|
|
42
|
+
"--allowedTools",
|
|
43
|
+
"mcp__lockwire__*",
|
|
44
|
+
"Read",
|
|
45
|
+
"Edit",
|
|
46
|
+
"Write",
|
|
47
|
+
"Glob",
|
|
48
|
+
"Grep",
|
|
49
|
+
"--permission-mode",
|
|
50
|
+
"acceptEdits",
|
|
51
|
+
"--max-turns",
|
|
52
|
+
String(opts.maxTurns ?? 40),
|
|
53
|
+
"--output-format",
|
|
54
|
+
"json",
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
command: "codex",
|
|
59
|
+
// MCP servers come from $CODEX_HOME/config.toml, which autofix writes (see codexHome).
|
|
60
|
+
args: ["exec", "--sandbox", "workspace-write", "--skip-git-repo-check", prompt],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Claude Code's `--mcp-config` file: lockwire's MCP server, started the way this lockwire runs. */
|
|
64
|
+
export function mcpConfigJson(lockwire) {
|
|
65
|
+
return `${JSON.stringify({ mcpServers: { lockwire: { command: lockwire.command, args: lockwire.args } } }, null, 2)}\n`;
|
|
66
|
+
}
|
|
67
|
+
const tomlString = (s) => JSON.stringify(s); // a TOML basic string is a JSON string
|
|
68
|
+
/** Codex's config.toml: lockwire's MCP server, required, its tools approved without prompting. */
|
|
69
|
+
export function codexConfigToml(lockwire) {
|
|
70
|
+
return [
|
|
71
|
+
"[mcp_servers.lockwire]",
|
|
72
|
+
`command = ${tomlString(lockwire.command)}`,
|
|
73
|
+
`args = [${lockwire.args.map(tomlString).join(", ")}]`,
|
|
74
|
+
"required = true",
|
|
75
|
+
'default_tools_approval_mode = "approve"',
|
|
76
|
+
"",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
function defaultLockwireCommand() {
|
|
80
|
+
const cli = join(fileURLToPath(new URL(".", import.meta.url)), "cli.js");
|
|
81
|
+
return { command: process.execPath, args: [cli, "mcp"] };
|
|
82
|
+
}
|
|
83
|
+
/** A file's content hash, read in chunks (any size); a symlink hashes as its target path. */
|
|
84
|
+
function fileHash(abs) {
|
|
85
|
+
try {
|
|
86
|
+
const st = lstatSync(abs);
|
|
87
|
+
if (st.isSymbolicLink())
|
|
88
|
+
return `link:${readlinkSync(abs)}`;
|
|
89
|
+
if (!st.isFile())
|
|
90
|
+
return null; // a FIFO or device could block a read forever
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null; // vanished mid-walk
|
|
94
|
+
}
|
|
95
|
+
let fd;
|
|
96
|
+
try {
|
|
97
|
+
fd = openSync(abs, "r");
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const hash = createHash("sha256");
|
|
104
|
+
const buf = Buffer.allocUnsafe(1 << 20);
|
|
105
|
+
for (let n = readSync(fd, buf, 0, buf.length, null); n > 0; n = readSync(fd, buf, 0, buf.length, null))
|
|
106
|
+
hash.update(buf.subarray(0, n));
|
|
107
|
+
return hash.digest("hex");
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Files under `dir` (repo-relative, posix), or none if it isn't a directory. */
|
|
117
|
+
function filesUnder(repoRoot, dir) {
|
|
118
|
+
try {
|
|
119
|
+
if (!lstatSync(join(repoRoot, dir)).isDirectory())
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
return walkFiles(join(repoRoot, dir)).map((rel) => `${dir}/${rel}`);
|
|
126
|
+
}
|
|
127
|
+
/** lockwire's own scratch files, which its tools rewrite on every call. */
|
|
128
|
+
const LOCKWIRE_SCRATCH = /^\.lockwire\/(cache\/|lock$|hook\.log$)/;
|
|
129
|
+
/**
|
|
130
|
+
* Content hashes of everything a run could change that matters: the tree (minus .git and
|
|
131
|
+
* node_modules), lockwire's state in .lockwire/ (its config and ledger, not its cache), and the git
|
|
132
|
+
* settings that run code on the next git command (.git/config, hooks, info).
|
|
133
|
+
*/
|
|
134
|
+
function treeHashes(repoRoot) {
|
|
135
|
+
const out = new Map();
|
|
136
|
+
const paths = [
|
|
137
|
+
...walkFiles(repoRoot),
|
|
138
|
+
...filesUnder(repoRoot, ".lockwire").filter((p) => !LOCKWIRE_SCRATCH.test(p)),
|
|
139
|
+
".git/config",
|
|
140
|
+
...filesUnder(repoRoot, ".git/hooks"),
|
|
141
|
+
...filesUnder(repoRoot, ".git/info"),
|
|
142
|
+
];
|
|
143
|
+
for (const rel of paths) {
|
|
144
|
+
const h = fileHash(join(repoRoot, rel));
|
|
145
|
+
if (h !== null)
|
|
146
|
+
out.set(rel, h);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/** Paths whose entry differs; if one side is missing altogether (a repo appeared or vanished), all of them. */
|
|
151
|
+
function diffMaps(before, after) {
|
|
152
|
+
if (!before || !after)
|
|
153
|
+
return [...(before ?? after ?? new Map()).keys()];
|
|
154
|
+
const changed = new Set();
|
|
155
|
+
for (const [p, h] of after)
|
|
156
|
+
if (before.get(p) !== h)
|
|
157
|
+
changed.add(p);
|
|
158
|
+
for (const p of before.keys())
|
|
159
|
+
if (!after.has(p))
|
|
160
|
+
changed.add(p);
|
|
161
|
+
return [...changed];
|
|
162
|
+
}
|
|
163
|
+
/** How long an agent gets to exit after SIGTERM before everything it started is killed. */
|
|
164
|
+
const KILL_GRACE_MS = 2000;
|
|
165
|
+
function run(command, args, opts) {
|
|
166
|
+
// On POSIX the agent leads its own process group, so a timeout (or the end of the run) stops
|
|
167
|
+
// everything it started, not just the shell or launcher at the top: nothing it spawned can keep
|
|
168
|
+
// writing to the tree after lockwire has judged it.
|
|
169
|
+
const group = process.platform !== "win32";
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
let child;
|
|
172
|
+
try {
|
|
173
|
+
child = spawn(command, args, {
|
|
174
|
+
cwd: opts.cwd,
|
|
175
|
+
env: opts.env,
|
|
176
|
+
shell: opts.shell,
|
|
177
|
+
detached: group,
|
|
178
|
+
// The agent's output goes to stderr, so `lockwire autofix --json` keeps stdout for the report.
|
|
179
|
+
stdio: ["ignore", opts.show ? 2 : "ignore", opts.show ? 2 : "ignore"],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
resolve({ exitCode: null, error: String(err) });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const kill = (signal) => {
|
|
187
|
+
if (child.pid === undefined)
|
|
188
|
+
return;
|
|
189
|
+
try {
|
|
190
|
+
if (group)
|
|
191
|
+
process.kill(-child.pid, signal);
|
|
192
|
+
else if (signal === "SIGKILL")
|
|
193
|
+
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
194
|
+
else
|
|
195
|
+
child.kill(signal);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// already gone
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
let timedOut = false;
|
|
202
|
+
let hard;
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
timedOut = true;
|
|
205
|
+
if (!group)
|
|
206
|
+
return kill("SIGKILL"); // Windows: taskkill the tree while its root is still there
|
|
207
|
+
kill("SIGTERM");
|
|
208
|
+
hard = setTimeout(() => kill("SIGKILL"), KILL_GRACE_MS);
|
|
209
|
+
}, opts.timeoutMs);
|
|
210
|
+
// A detached group doesn't get the terminal's Ctrl-C: pass it on, then die of it as usual.
|
|
211
|
+
const signals = group ? ["SIGINT", "SIGTERM", "SIGHUP"] : [];
|
|
212
|
+
const onSignal = (signal) => {
|
|
213
|
+
kill("SIGKILL");
|
|
214
|
+
for (const s of signals)
|
|
215
|
+
process.off(s, onSignal);
|
|
216
|
+
process.kill(process.pid, signal);
|
|
217
|
+
};
|
|
218
|
+
for (const s of signals)
|
|
219
|
+
process.on(s, onSignal);
|
|
220
|
+
let done = false;
|
|
221
|
+
const finish = (result) => {
|
|
222
|
+
if (done)
|
|
223
|
+
return;
|
|
224
|
+
done = true;
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
clearTimeout(hard);
|
|
227
|
+
for (const s of signals)
|
|
228
|
+
process.off(s, onSignal);
|
|
229
|
+
if (group)
|
|
230
|
+
kill("SIGKILL"); // whatever the agent left running in the background
|
|
231
|
+
resolve(result);
|
|
232
|
+
};
|
|
233
|
+
child.on("error", (err) => finish({ exitCode: null, error: err.message }));
|
|
234
|
+
child.on("exit", (code, signal) => finish(timedOut
|
|
235
|
+
? {
|
|
236
|
+
exitCode: null,
|
|
237
|
+
error: `stopped after the ${Math.round(opts.timeoutMs / 1000)}s timeout`,
|
|
238
|
+
}
|
|
239
|
+
: signal
|
|
240
|
+
? { exitCode: null, error: `stopped by ${signal}` }
|
|
241
|
+
: { exitCode: code }));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const RESOLUTION_EVENTS = new Set([
|
|
245
|
+
"anchor.resolved",
|
|
246
|
+
"anchor.created",
|
|
247
|
+
"anchor.acknowledged",
|
|
248
|
+
]);
|
|
249
|
+
/** lockwire's own files: a run may change them, but only through lockwire (see {@link lockwireStateProblems}). */
|
|
250
|
+
const LOCKWIRE_OWN = new Set(["lockwire.lock", ".lockwire/ledger.jsonl"]);
|
|
251
|
+
const readText = (path) => (existsSync(path) ? readFileSync(path, "utf8") : "");
|
|
252
|
+
const errorText = (err) => (err instanceof Error ? err.message : String(err));
|
|
253
|
+
/** A task's identity across a run: an unlinked marker's id carries its line, which edits above it shift. */
|
|
254
|
+
const identity = (t) => t.id.startsWith("unlinked:") ? t.id.replace(/:\d+:([0-9a-f]{8})$/, ":$1") : t.id;
|
|
255
|
+
/** How many of the tasks before a run are gone after it. New problems don't offset fixed ones. */
|
|
256
|
+
export function fixedCount(before, after) {
|
|
257
|
+
const open = new Set(after.map(identity));
|
|
258
|
+
return before.filter((t) => !open.has(identity(t))).length;
|
|
259
|
+
}
|
|
260
|
+
/** An anchor as written, for comparison; a backfilled `claim.normHash` (lockwire check adds it) isn't a change. */
|
|
261
|
+
function comparable(a, was) {
|
|
262
|
+
if (a.claim && was.claim && !was.claim.normHash && a.claim.normHash) {
|
|
263
|
+
const { normHash: _, ...claim } = a.claim;
|
|
264
|
+
return canonicalJson({ ...a, claim });
|
|
265
|
+
}
|
|
266
|
+
return canonicalJson(a);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The ledger records a run appended, and anything wrong with how lockwire's state changed. lockwire's
|
|
270
|
+
* tools only append verified records to the ledger, and change an anchor only with a record for it;
|
|
271
|
+
* an agent editing either file by hand (to make a claim look fixed) breaks one of those rules.
|
|
272
|
+
*/
|
|
273
|
+
function lockwireStateProblems(ledgerBefore, ledgerAfter, lockBefore, lockAfter) {
|
|
274
|
+
const problems = [];
|
|
275
|
+
const added = [];
|
|
276
|
+
if (!ledgerAfter.startsWith(ledgerBefore)) {
|
|
277
|
+
problems.push("The ledger was rewritten during the run: lockwire only ever appends to it.");
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const lines = ledgerAfter.slice(ledgerBefore.length).split("\n");
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
if (!line.trim())
|
|
283
|
+
continue;
|
|
284
|
+
const record = verifiedRecord(line.replace(/\r$/, ""));
|
|
285
|
+
if (record)
|
|
286
|
+
added.push(record);
|
|
287
|
+
else
|
|
288
|
+
problems.push("A ledger record added during the run doesn't verify (its hash doesn't match): it was written by hand, not by lockwire.");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const recorded = new Set(added.map((r) => r.anchor));
|
|
292
|
+
const created = new Set(added.filter((r) => r.event === "anchor.created").map((r) => r.anchor));
|
|
293
|
+
const was = new Map(lockBefore.anchors.map((a) => [a.id, a]));
|
|
294
|
+
for (const a of lockAfter.anchors) {
|
|
295
|
+
const prev = was.get(a.id);
|
|
296
|
+
if (prev && comparable(a, prev) === canonicalJson(prev))
|
|
297
|
+
continue;
|
|
298
|
+
if (prev ? !recorded.has(a.id) : !created.has(a.id))
|
|
299
|
+
problems.push(`lockwire.lock was edited by hand: anchor ${a.id} ${prev ? "changed" : "appeared"} with no ledger record of lockwire doing it.`);
|
|
300
|
+
}
|
|
301
|
+
return { added, problems: [...new Set(problems)] };
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Tasks a run silenced rather than fixed: the anchor was retired (superseded or waived) or dropped,
|
|
305
|
+
* but the claim is still in the doc, marker and all, or as the same sentence with its marker deleted.
|
|
306
|
+
* A wrong claim that's merely no longer tracked is still wrong.
|
|
307
|
+
*/
|
|
308
|
+
async function retiredInPlace(repoRoot, before, after, lockAfter) {
|
|
309
|
+
const open = new Set(after.map(identity));
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const t of before) {
|
|
312
|
+
if (t.id.startsWith("unlinked:") || open.has(identity(t)) || !t.doc)
|
|
313
|
+
continue;
|
|
314
|
+
const anchor = lockAfter.anchors.find((a) => a.id === t.id);
|
|
315
|
+
if (anchor && anchor.status !== "superseded" && anchor.status !== "waived")
|
|
316
|
+
continue;
|
|
317
|
+
const abs = resolveInsideRepo(repoRoot, t.doc);
|
|
318
|
+
if (!abs || !existsSync(abs))
|
|
319
|
+
continue;
|
|
320
|
+
const text = await readFile(abs, "utf8");
|
|
321
|
+
// A marker whose anchor is gone shows up as unlinked already; one whose anchor was retired doesn't.
|
|
322
|
+
const markerThere = scanMarkers(text).some((m) => m.id === t.id);
|
|
323
|
+
const sentence = (t.claim ?? "").replace(/…$/, "").replace(/\s+/g, " ").trim();
|
|
324
|
+
const sentenceThere = t.kind !== "marker-removed" &&
|
|
325
|
+
sentence.length >= 8 &&
|
|
326
|
+
text.replace(/\s+/g, " ").includes(sentence);
|
|
327
|
+
if ((markerThere && anchor) || (!markerThere && sentenceThere))
|
|
328
|
+
out.push({
|
|
329
|
+
...t,
|
|
330
|
+
summary: `retired (${anchor?.status ?? "removed from lockwire.lock"}) during the run, but the claim is still in ${t.doc}: fix or delete the sentence. Was: ${t.summary}`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
export async function autofix(repoRoot, opts = {}) {
|
|
336
|
+
const config = await readConfig(repoRoot);
|
|
337
|
+
const before = await listTasks(repoRoot, config, opts.scope);
|
|
338
|
+
const empty = { fixed: 0, problems: [], resolutions: [], changedFiles: [], outOfBounds: [] };
|
|
339
|
+
if (before.length === 0)
|
|
340
|
+
return {
|
|
341
|
+
status: "nothing-to-do",
|
|
342
|
+
before,
|
|
343
|
+
after: [],
|
|
344
|
+
...empty,
|
|
345
|
+
markdown: "lockwire: no documentation claims need attention.\n",
|
|
346
|
+
};
|
|
347
|
+
const lockwire = opts.lockwireCommand ?? defaultLockwireCommand();
|
|
348
|
+
const prompt = `${fixDocsPrompt(opts.scope)}\n\nThere are ${before.length} task${before.length === 1 ? "" : "s"} right now.`;
|
|
349
|
+
const work = mkdtempSync(join(tmpdir(), "lockwire-autofix-"));
|
|
350
|
+
const files = { prompt: join(work, "prompt.md"), mcpConfig: join(work, "mcp.json") };
|
|
351
|
+
writeFileSync(files.prompt, prompt, "utf8");
|
|
352
|
+
writeFileSync(files.mcpConfig, mcpConfigJson(lockwire), "utf8");
|
|
353
|
+
const env = {
|
|
354
|
+
...(opts.env ?? process.env),
|
|
355
|
+
LOCKWIRE_PROMPT_FILE: files.prompt,
|
|
356
|
+
LOCKWIRE_MCP_CONFIG: files.mcpConfig,
|
|
357
|
+
LOCKWIRE_REPO: repoRoot,
|
|
358
|
+
};
|
|
359
|
+
let command;
|
|
360
|
+
let args;
|
|
361
|
+
let shell = false;
|
|
362
|
+
if (opts.agentCommand) {
|
|
363
|
+
command = opts.agentCommand;
|
|
364
|
+
args = [];
|
|
365
|
+
shell = true;
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
const agent = opts.agent ?? "claude-code";
|
|
369
|
+
({ command, args } = agentArgv(agent, prompt, files, {
|
|
370
|
+
...(opts.maxTurns !== undefined ? { maxTurns: opts.maxTurns } : {}),
|
|
371
|
+
bare: Boolean(env.CI),
|
|
372
|
+
}));
|
|
373
|
+
if (agent === "codex") {
|
|
374
|
+
// Codex reads MCP servers from $CODEX_HOME/config.toml: give it one with lockwire in it, and
|
|
375
|
+
// the user's own login if they have one (CI passes CODEX_API_KEY instead).
|
|
376
|
+
const home = join(work, "codex-home");
|
|
377
|
+
mkdirSync(home);
|
|
378
|
+
writeFileSync(join(home, "config.toml"), codexConfigToml(lockwire), "utf8");
|
|
379
|
+
const auth = join(env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json");
|
|
380
|
+
if (existsSync(auth))
|
|
381
|
+
copyFileSync(auth, join(home, "auth.json"));
|
|
382
|
+
env.CODEX_HOME = home;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const shown = shell
|
|
386
|
+
? command
|
|
387
|
+
: [command, ...args.map((a) => (a === prompt ? "<fix-docs prompt>" : a))].join(" ");
|
|
388
|
+
if (opts.dryRun) {
|
|
389
|
+
rmSync(work, { recursive: true, force: true });
|
|
390
|
+
return {
|
|
391
|
+
status: "partial",
|
|
392
|
+
before,
|
|
393
|
+
after: before,
|
|
394
|
+
...empty,
|
|
395
|
+
agent: { command: shown, exitCode: null },
|
|
396
|
+
markdown: `Dry run: would run \`${shown}\` on ${before.length} task(s).\n`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// Everything the verdict is judged against is taken before the agent starts, never from state the
|
|
400
|
+
// agent could have edited: which files are docs, the anchors, the ledger, the commit and the index.
|
|
401
|
+
const lockBefore = await readLockfile(repoRoot);
|
|
402
|
+
const knownDocs = new Set(lockBefore.anchors.flatMap((a) => (a.doc ? [a.doc] : [])));
|
|
403
|
+
const ledgerBefore = readText(ledgerPath(repoRoot));
|
|
404
|
+
const headBefore = headCommit(repoRoot);
|
|
405
|
+
const indexBefore = indexEntries(repoRoot);
|
|
406
|
+
const treeBefore = treeHashes(repoRoot);
|
|
407
|
+
const outcome = await run(command, args, {
|
|
408
|
+
cwd: repoRoot,
|
|
409
|
+
env,
|
|
410
|
+
shell,
|
|
411
|
+
timeoutMs: opts.timeoutMs ?? 30 * 60 * 1000,
|
|
412
|
+
show: opts.showAgentOutput !== false,
|
|
413
|
+
});
|
|
414
|
+
rmSync(work, { recursive: true, force: true });
|
|
415
|
+
// The verdict is lockwire's, from scratch, not the agent's account of itself.
|
|
416
|
+
const problems = [];
|
|
417
|
+
const changed = new Set(diffMaps(treeBefore, treeHashes(repoRoot)));
|
|
418
|
+
for (const p of diffMaps(indexBefore, indexEntries(repoRoot)))
|
|
419
|
+
changed.add(p); // staged, not on disk
|
|
420
|
+
const changedFiles = [...changed].sort();
|
|
421
|
+
const outOfBounds = changedFiles.filter((f) => !LOCKWIRE_OWN.has(f) && !knownDocs.has(f) && !isScannedDoc(f, config));
|
|
422
|
+
const headAfter = headCommit(repoRoot);
|
|
423
|
+
if (headAfter !== headBefore)
|
|
424
|
+
problems.push(`The agent moved HEAD (${headBefore ?? "no commit"} to ${headAfter ?? "no commit"}): it committed or checked something out, and its commits would ride into the pull request.`);
|
|
425
|
+
let after = before;
|
|
426
|
+
let lockAfter = lockBefore;
|
|
427
|
+
let added = [];
|
|
428
|
+
try {
|
|
429
|
+
lockAfter = await readLockfile(repoRoot);
|
|
430
|
+
const state = lockwireStateProblems(ledgerBefore, readText(ledgerPath(repoRoot)), lockBefore, lockAfter);
|
|
431
|
+
added = state.added;
|
|
432
|
+
problems.push(...state.problems);
|
|
433
|
+
after = await listTasks(repoRoot, config, opts.scope);
|
|
434
|
+
after = [...after, ...(await retiredInPlace(repoRoot, before, after, lockAfter))];
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
problems.push(`lockwire couldn't re-check the claims after the run: ${errorText(err)}`);
|
|
438
|
+
}
|
|
439
|
+
const resolutions = added
|
|
440
|
+
.filter((e) => RESOLUTION_EVENTS.has(e.event))
|
|
441
|
+
.map((e) => {
|
|
442
|
+
const a = lockAfter.anchors.find((x) => x.id === e.anchor);
|
|
443
|
+
return {
|
|
444
|
+
event: e.event,
|
|
445
|
+
anchor: e.anchor,
|
|
446
|
+
doc: a?.doc ?? null,
|
|
447
|
+
target: a ? `${a.target.path}${a.target.symbol ? `#${a.target.symbol}` : ""}` : "?",
|
|
448
|
+
claim: a?.claim?.excerpt ?? null,
|
|
449
|
+
note: e.note ?? null,
|
|
450
|
+
by: e.actor.tool?.name ?? e.actor.type,
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
const agentFailed = outcome.exitCode !== 0;
|
|
454
|
+
const status = outOfBounds.length > 0 ||
|
|
455
|
+
problems.length > 0 ||
|
|
456
|
+
(agentFailed && changedFiles.length === 0 && after.length === before.length)
|
|
457
|
+
? "refused"
|
|
458
|
+
: after.length === 0
|
|
459
|
+
? "fixed"
|
|
460
|
+
: "partial";
|
|
461
|
+
const report = {
|
|
462
|
+
status,
|
|
463
|
+
before,
|
|
464
|
+
after,
|
|
465
|
+
fixed: fixedCount(before, after),
|
|
466
|
+
problems,
|
|
467
|
+
resolutions,
|
|
468
|
+
changedFiles,
|
|
469
|
+
outOfBounds,
|
|
470
|
+
agent: {
|
|
471
|
+
command: shown,
|
|
472
|
+
exitCode: outcome.exitCode,
|
|
473
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
474
|
+
},
|
|
475
|
+
markdown: "",
|
|
476
|
+
};
|
|
477
|
+
report.markdown = summaryMarkdown(report);
|
|
478
|
+
return report;
|
|
479
|
+
}
|
|
480
|
+
const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\r\n|\r|\n/g, " ");
|
|
481
|
+
/** A pull-request body for an autofix run. */
|
|
482
|
+
export function summaryMarkdown(r) {
|
|
483
|
+
const lines = [];
|
|
484
|
+
const fixed = fixedCount(r.before, r.after);
|
|
485
|
+
lines.push(r.status === "refused"
|
|
486
|
+
? "## lockwire autofix: refused"
|
|
487
|
+
: `## lockwire autofix: ${fixed} of ${r.before.length} documentation claim${r.before.length === 1 ? "" : "s"} fixed`, "");
|
|
488
|
+
if (r.outOfBounds.length > 0)
|
|
489
|
+
lines.push("The agent changed files that aren't documentation, so this run must not be merged as a docs fix:", "", ...r.outOfBounds.map((f) => `- \`${cell(f)}\``), "");
|
|
490
|
+
if ((r.problems ?? []).length > 0)
|
|
491
|
+
lines.push(...(r.problems ?? []).map((p) => `- ${cell(p)}`), "");
|
|
492
|
+
if (r.agent?.error || (r.agent && r.agent.exitCode !== 0))
|
|
493
|
+
lines.push(`The agent (\`${cell(r.agent.command)}\`) ${r.agent.error ? `failed: ${cell(r.agent.error)}` : `exited with code ${r.agent.exitCode}`}.`, "");
|
|
494
|
+
if (r.resolutions.length > 0) {
|
|
495
|
+
lines.push("| Claim | Bound to | What the agent did |", "| --- | --- | --- |");
|
|
496
|
+
for (const x of r.resolutions)
|
|
497
|
+
lines.push(`| ${cell(x.doc)}: ${cell(x.claim)} | \`${cell(x.target)}\` | ${cell(x.event === "anchor.acknowledged" ? `retired (${x.note ?? "superseded"})` : (x.note ?? "re-stamped"))} |`);
|
|
498
|
+
lines.push("");
|
|
499
|
+
}
|
|
500
|
+
if (r.after.length > 0) {
|
|
501
|
+
lines.push(`Still open (${r.after.length}):`, "", ...r.after.map((t) => `- \`${cell(t.id)}\` ${t.kind} — ${cell(t.doc ?? "(lockfile-only)")}:${t.line ?? "-"}: ${cell(t.summary)}`), "");
|
|
502
|
+
}
|
|
503
|
+
lines.push("Every change above was re-checked by lockwire after the agent finished: a number that still disagreed with its data, or a marker pointing at nothing, would still be listed as open. `lockwire history <id>` shows each claim's full record.");
|
|
504
|
+
return `${lines.join("\n")}\n`;
|
|
505
|
+
}
|
|
506
|
+
//# sourceMappingURL=autofix.js.map
|