@forwardimpact/libharness 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/package.json +14 -12
- package/src/agent-runner.js +3 -3
- package/src/benchmark/task-family.js +1 -1
- package/src/benchmark/trace-split.js +1 -1
- package/src/claude-code-executable.js +1 -1
- package/src/commands/benchmark-definition.js +10 -10
- package/src/commands/benchmark-grade.js +1 -1
- package/src/commands/benchmark-report.js +1 -1
- package/src/commands/benchmark-run.js +1 -1
- package/src/commands/by-discussion.js +1 -1
- package/src/commands/facilitate.js +1 -1
- package/src/commands/output.js +1 -1
- package/src/commands/run.js +1 -1
- package/src/commands/scan-logs.js +2 -2
- package/src/commands/selfedit.js +124 -0
- package/src/commands/supervise.js +2 -2
- package/src/commands/tee.js +1 -1
- package/src/commands/trace.js +1 -1
- package/src/cost.js +1 -1
- package/src/trace-collector.js +1 -1
- package/src/trace-multi.js +1 -1
- package/src/trace-render.js +1 -1
- package/bin/fit-benchmark.js +0 -44
- package/bin/fit-harness.js +0 -412
- package/bin/fit-selfedit.js +0 -165
- package/bin/fit-trace.js +0 -520
package/bin/fit-harness.js
DELETED
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import "@forwardimpact/libpreflight/node22";
|
|
4
|
-
|
|
5
|
-
import { createCli } from "@forwardimpact/libcli";
|
|
6
|
-
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
|
|
7
|
-
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
8
|
-
|
|
9
|
-
import { runOutputCommand } from "../src/commands/output.js";
|
|
10
|
-
import { runTeeCommand } from "../src/commands/tee.js";
|
|
11
|
-
import { runRunCommand } from "../src/commands/run.js";
|
|
12
|
-
import { runSuperviseCommand } from "../src/commands/supervise.js";
|
|
13
|
-
import { runFacilitateCommand } from "../src/commands/facilitate.js";
|
|
14
|
-
import { runDiscussCommand } from "../src/commands/discuss.js";
|
|
15
|
-
import { runCallbackCommand } from "../src/commands/callback.js";
|
|
16
|
-
import { runScanLogsCommand } from "../src/commands/scan-logs.js";
|
|
17
|
-
import { AGENT_MODEL, LEAD_MODEL } from "@forwardimpact/libutil/models";
|
|
18
|
-
|
|
19
|
-
const LEAD_OPTIONS = {
|
|
20
|
-
"lead-profile": {
|
|
21
|
-
type: "string",
|
|
22
|
-
description: "Lead role profile name (supervisor / facilitator / chair)",
|
|
23
|
-
},
|
|
24
|
-
"lead-model": {
|
|
25
|
-
type: "string",
|
|
26
|
-
description: `Claude model for the lead role (default: ${LEAD_MODEL})`,
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// Advisor consult flags, shared by all four session modes.
|
|
31
|
-
const ADVISOR_OPTIONS = {
|
|
32
|
-
"advisor-model": {
|
|
33
|
-
type: "string",
|
|
34
|
-
description:
|
|
35
|
-
"Claude model for advisor consults; omitting the flag disables the Advisor tool (default: off)",
|
|
36
|
-
},
|
|
37
|
-
"advisor-max-uses": {
|
|
38
|
-
type: "string",
|
|
39
|
-
description:
|
|
40
|
-
"Session-wide consult budget shared by all participants (default: 3; requires --advisor-model)",
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// Shared task-input flags: --task-file (path), --task-text (inline), and
|
|
45
|
-
// --task-event (path to native GitHub event JSON composed into a task via
|
|
46
|
-
// libharness/src/events/github.js). Exactly one of the three is required.
|
|
47
|
-
const TASK_INPUT_OPTIONS = {
|
|
48
|
-
"task-file": {
|
|
49
|
-
type: "string",
|
|
50
|
-
description: "Path to a markdown task file",
|
|
51
|
-
},
|
|
52
|
-
"task-text": {
|
|
53
|
-
type: "string",
|
|
54
|
-
description: "Inline task text (alternative to --task-file)",
|
|
55
|
-
},
|
|
56
|
-
"task-event": {
|
|
57
|
-
type: "string",
|
|
58
|
-
description:
|
|
59
|
-
"Path to a native GitHub event payload JSON, composed into the task via libharness/src/events/github.js (reads $GITHUB_EVENT_NAME)",
|
|
60
|
-
},
|
|
61
|
-
"task-amend": {
|
|
62
|
-
type: "string",
|
|
63
|
-
description: "Additional text appended to the task",
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const definition = {
|
|
68
|
-
name: "fit-harness",
|
|
69
|
-
description:
|
|
70
|
-
"Run agents and capture NDJSON traces — for agent evaluations or multi-agent collaboration",
|
|
71
|
-
commands: [
|
|
72
|
-
{
|
|
73
|
-
name: "run",
|
|
74
|
-
args: [],
|
|
75
|
-
argsUsage: "",
|
|
76
|
-
handler: runRunCommand,
|
|
77
|
-
description: "Run a single agent autonomously on a defined task",
|
|
78
|
-
options: {
|
|
79
|
-
...TASK_INPUT_OPTIONS,
|
|
80
|
-
"agent-model": {
|
|
81
|
-
type: "string",
|
|
82
|
-
description: `Claude model for the agent (default: ${AGENT_MODEL})`,
|
|
83
|
-
},
|
|
84
|
-
"max-turns": {
|
|
85
|
-
type: "string",
|
|
86
|
-
description: "Max agentic turns (default: 50, 0 = unlimited)",
|
|
87
|
-
},
|
|
88
|
-
output: {
|
|
89
|
-
type: "string",
|
|
90
|
-
description: "Write the NDJSON trace to a file",
|
|
91
|
-
},
|
|
92
|
-
cwd: { type: "string", description: "Working directory for the agent" },
|
|
93
|
-
"agent-profile": {
|
|
94
|
-
type: "string",
|
|
95
|
-
description: "Agent profile name to load",
|
|
96
|
-
},
|
|
97
|
-
"work-tracker": {
|
|
98
|
-
type: "string",
|
|
99
|
-
description:
|
|
100
|
-
"Active work-item tracker (github|filesystem, default: github)",
|
|
101
|
-
},
|
|
102
|
-
"allowed-tools": {
|
|
103
|
-
type: "string",
|
|
104
|
-
description: "Comma-separated tool allowlist",
|
|
105
|
-
},
|
|
106
|
-
"mcp-server": {
|
|
107
|
-
type: "string",
|
|
108
|
-
description:
|
|
109
|
-
"Connect to the MCP service (e.g. --mcp-server=guide); adds mcp__<name>__* to allowed tools",
|
|
110
|
-
},
|
|
111
|
-
...ADVISOR_OPTIONS,
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
name: "supervise",
|
|
116
|
-
args: [],
|
|
117
|
-
argsUsage: "",
|
|
118
|
-
handler: runSuperviseCommand,
|
|
119
|
-
description:
|
|
120
|
-
"Run a supervisor–agent relay — typical shape for agent-as-judge evaluations",
|
|
121
|
-
options: {
|
|
122
|
-
...TASK_INPUT_OPTIONS,
|
|
123
|
-
"agent-model": {
|
|
124
|
-
type: "string",
|
|
125
|
-
description: `Claude model for the agent (default: ${AGENT_MODEL})`,
|
|
126
|
-
},
|
|
127
|
-
...LEAD_OPTIONS,
|
|
128
|
-
"max-turns": {
|
|
129
|
-
type: "string",
|
|
130
|
-
description:
|
|
131
|
-
"Max agentic turns per runner invocation (default: 200, 0 = unlimited)",
|
|
132
|
-
},
|
|
133
|
-
output: {
|
|
134
|
-
type: "string",
|
|
135
|
-
description: "Write the NDJSON trace to a file",
|
|
136
|
-
},
|
|
137
|
-
"agent-profile": { type: "string", description: "Agent profile name" },
|
|
138
|
-
"work-tracker": {
|
|
139
|
-
type: "string",
|
|
140
|
-
description:
|
|
141
|
-
"Active work-item tracker (github|filesystem, default: github)",
|
|
142
|
-
},
|
|
143
|
-
"allowed-tools": {
|
|
144
|
-
type: "string",
|
|
145
|
-
description: "Agent tool allowlist",
|
|
146
|
-
},
|
|
147
|
-
"supervisor-cwd": {
|
|
148
|
-
type: "string",
|
|
149
|
-
description: "Supervisor working directory",
|
|
150
|
-
},
|
|
151
|
-
"agent-cwd": { type: "string", description: "Agent working directory" },
|
|
152
|
-
"supervisor-allowed-tools": {
|
|
153
|
-
type: "string",
|
|
154
|
-
description: "Supervisor tool allowlist",
|
|
155
|
-
},
|
|
156
|
-
"mcp-server": {
|
|
157
|
-
type: "string",
|
|
158
|
-
description:
|
|
159
|
-
"Connect to the MCP service (e.g. --mcp-server=guide); adds mcp__<name>__* to allowed tools",
|
|
160
|
-
},
|
|
161
|
-
...ADVISOR_OPTIONS,
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
name: "facilitate",
|
|
166
|
-
args: [],
|
|
167
|
-
argsUsage: "",
|
|
168
|
-
handler: runFacilitateCommand,
|
|
169
|
-
description:
|
|
170
|
-
"Run a facilitator with N participants — typical shape for multi-agent collaboration",
|
|
171
|
-
options: {
|
|
172
|
-
...TASK_INPUT_OPTIONS,
|
|
173
|
-
"agent-model": {
|
|
174
|
-
type: "string",
|
|
175
|
-
description: `Claude model for agents (default: ${AGENT_MODEL})`,
|
|
176
|
-
},
|
|
177
|
-
...LEAD_OPTIONS,
|
|
178
|
-
"max-turns": {
|
|
179
|
-
type: "string",
|
|
180
|
-
description: "Max agentic turns (default: 20, 0 = unlimited)",
|
|
181
|
-
},
|
|
182
|
-
output: {
|
|
183
|
-
type: "string",
|
|
184
|
-
description: "Write the NDJSON trace to a file",
|
|
185
|
-
},
|
|
186
|
-
"facilitator-cwd": {
|
|
187
|
-
type: "string",
|
|
188
|
-
description: "Facilitator working directory",
|
|
189
|
-
},
|
|
190
|
-
"agent-profiles": {
|
|
191
|
-
type: "string",
|
|
192
|
-
description:
|
|
193
|
-
"Comma-separated list of participant profile names (required)",
|
|
194
|
-
},
|
|
195
|
-
"agent-cwd": {
|
|
196
|
-
type: "string",
|
|
197
|
-
description: "Working directory shared by participants (default: .)",
|
|
198
|
-
},
|
|
199
|
-
"work-tracker": {
|
|
200
|
-
type: "string",
|
|
201
|
-
description:
|
|
202
|
-
"Active work-item tracker (github|filesystem, default: github)",
|
|
203
|
-
},
|
|
204
|
-
...ADVISOR_OPTIONS,
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
{
|
|
208
|
-
name: "discuss",
|
|
209
|
-
args: [],
|
|
210
|
-
argsUsage: "",
|
|
211
|
-
handler: runDiscussCommand,
|
|
212
|
-
description:
|
|
213
|
-
"Run an async, suspendable discussion — Chair + N participants + bridge callback",
|
|
214
|
-
options: {
|
|
215
|
-
...TASK_INPUT_OPTIONS,
|
|
216
|
-
"agent-model": {
|
|
217
|
-
type: "string",
|
|
218
|
-
description: `Claude model for agents (default: ${AGENT_MODEL})`,
|
|
219
|
-
},
|
|
220
|
-
...LEAD_OPTIONS,
|
|
221
|
-
"max-turns": {
|
|
222
|
-
type: "string",
|
|
223
|
-
description: "Max agentic turns (default: 40, 0 = unlimited)",
|
|
224
|
-
},
|
|
225
|
-
output: {
|
|
226
|
-
type: "string",
|
|
227
|
-
description: "Write the NDJSON trace to a file",
|
|
228
|
-
},
|
|
229
|
-
"agent-profiles": {
|
|
230
|
-
type: "string",
|
|
231
|
-
description: "Comma-separated participant profile names (optional)",
|
|
232
|
-
},
|
|
233
|
-
"agent-cwd": {
|
|
234
|
-
type: "string",
|
|
235
|
-
description: "Working directory shared by participants (default: .)",
|
|
236
|
-
},
|
|
237
|
-
"discussion-id": {
|
|
238
|
-
type: "string",
|
|
239
|
-
description:
|
|
240
|
-
"Stable id for the threaded conversation; carried through traces for linking",
|
|
241
|
-
},
|
|
242
|
-
"resume-context": {
|
|
243
|
-
type: "string",
|
|
244
|
-
description: "JSON-serialized prior state for a resumed run",
|
|
245
|
-
},
|
|
246
|
-
"work-tracker": {
|
|
247
|
-
type: "string",
|
|
248
|
-
description:
|
|
249
|
-
"Active work-item tracker (github|filesystem, default: github)",
|
|
250
|
-
},
|
|
251
|
-
...ADVISOR_OPTIONS,
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
name: "output",
|
|
256
|
-
args: [],
|
|
257
|
-
argsUsage: "",
|
|
258
|
-
handler: runOutputCommand,
|
|
259
|
-
description:
|
|
260
|
-
"Read NDJSON from stdin and emit a structured or readable form",
|
|
261
|
-
},
|
|
262
|
-
{
|
|
263
|
-
name: "tee",
|
|
264
|
-
args: ["output"],
|
|
265
|
-
argsUsage: "[output.ndjson]",
|
|
266
|
-
handler: runTeeCommand,
|
|
267
|
-
description:
|
|
268
|
-
"Stream readable text to stdout while saving raw NDJSON to a file",
|
|
269
|
-
},
|
|
270
|
-
{
|
|
271
|
-
name: "callback",
|
|
272
|
-
args: [],
|
|
273
|
-
argsUsage: "",
|
|
274
|
-
handler: runCallbackCommand,
|
|
275
|
-
description:
|
|
276
|
-
"Extract the terminal summary from an NDJSON trace and POST it to a callback URL",
|
|
277
|
-
options: {
|
|
278
|
-
"trace-file": {
|
|
279
|
-
type: "string",
|
|
280
|
-
description: "Path to the NDJSON trace file",
|
|
281
|
-
},
|
|
282
|
-
"callback-url": {
|
|
283
|
-
type: "string",
|
|
284
|
-
description: "URL to POST the summary to",
|
|
285
|
-
},
|
|
286
|
-
"correlation-id": {
|
|
287
|
-
type: "string",
|
|
288
|
-
description: "Correlation ID to include in the payload",
|
|
289
|
-
},
|
|
290
|
-
"run-url": {
|
|
291
|
-
type: "string",
|
|
292
|
-
description: "GitHub Actions run URL (optional)",
|
|
293
|
-
},
|
|
294
|
-
"discussion-id": {
|
|
295
|
-
type: "string",
|
|
296
|
-
description:
|
|
297
|
-
"Discussion id (fallback when the trace lacks a meta event)",
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
name: "scan-logs",
|
|
303
|
-
args: [],
|
|
304
|
-
argsUsage: "",
|
|
305
|
-
handler: runScanLogsCommand,
|
|
306
|
-
description:
|
|
307
|
-
"Scan a run's log archive for secret literals; exit non-zero on any hit, fail closed on an unreadable archive",
|
|
308
|
-
options: {
|
|
309
|
-
archive: {
|
|
310
|
-
type: "string",
|
|
311
|
-
description: "Path to an already-resolved log archive (.zip)",
|
|
312
|
-
},
|
|
313
|
-
"run-id": {
|
|
314
|
-
type: "string",
|
|
315
|
-
description:
|
|
316
|
-
"GitHub Actions run id to download the log archive for (with --repo)",
|
|
317
|
-
},
|
|
318
|
-
repo: {
|
|
319
|
-
type: "string",
|
|
320
|
-
description: "owner/repo for the --run-id download",
|
|
321
|
-
},
|
|
322
|
-
secret: {
|
|
323
|
-
type: "string",
|
|
324
|
-
multiple: true,
|
|
325
|
-
description:
|
|
326
|
-
"Repeatable label=literal; the literal (everything after the first =) is searched for in the logs",
|
|
327
|
-
},
|
|
328
|
-
},
|
|
329
|
-
},
|
|
330
|
-
],
|
|
331
|
-
globalOptions: {
|
|
332
|
-
format: { type: "string", description: "Output format (json|text)" },
|
|
333
|
-
help: { type: "boolean", short: "h", description: "Show this help" },
|
|
334
|
-
version: { type: "boolean", description: "Show version" },
|
|
335
|
-
json: { type: "boolean", description: "Output help as JSON" },
|
|
336
|
-
},
|
|
337
|
-
examples: [
|
|
338
|
-
"fit-harness run --task-file=task.md --output=trace.ndjson",
|
|
339
|
-
"fit-harness run --task-file=task.md --work-tracker=filesystem --output=trace.ndjson",
|
|
340
|
-
"fit-harness supervise --task-file=task.md --lead-profile=judge --agent-profile=coder --output=trace.ndjson",
|
|
341
|
-
'fit-harness facilitate --task-file=task.md --lead-profile=lead --agent-profiles="security-engineer,technical-writer" --output=trace.ndjson',
|
|
342
|
-
'fit-harness discuss --task-file=task.md --lead-profile=release-engineer --agent-profiles="staff-engineer,security-engineer" --discussion-id=GD_kw...',
|
|
343
|
-
"fit-harness output --format=text < trace.ndjson",
|
|
344
|
-
],
|
|
345
|
-
documentation: [
|
|
346
|
-
{
|
|
347
|
-
title: "Coordinate an Agent Team",
|
|
348
|
-
url: "https://www.forwardimpact.team/docs/libraries/coordinate-team/index.md",
|
|
349
|
-
description:
|
|
350
|
-
"Run a lead and N participant agents in one async session — supervise, facilitate, or discuss — with Ask/Answer/Announce and a single NDJSON trace.",
|
|
351
|
-
},
|
|
352
|
-
{
|
|
353
|
-
title: "Run an Eval",
|
|
354
|
-
url: "https://www.forwardimpact.team/docs/libraries/prove-changes/run-eval/index.md",
|
|
355
|
-
description:
|
|
356
|
-
"Author a judge profile, run an eval locally, wire it into CI, and inspect the resulting trace.",
|
|
357
|
-
},
|
|
358
|
-
{
|
|
359
|
-
title: "Prove Agent Changes",
|
|
360
|
-
url: "https://www.forwardimpact.team/docs/libraries/prove-changes/index.md",
|
|
361
|
-
description:
|
|
362
|
-
"End-to-end workflow from dataset generation through evaluation to trace analysis, including multi-agent collaboration sessions.",
|
|
363
|
-
},
|
|
364
|
-
{
|
|
365
|
-
title: "Analyze Traces",
|
|
366
|
-
url: "https://www.forwardimpact.team/docs/libraries/prove-changes/trace-analysis/index.md",
|
|
367
|
-
description:
|
|
368
|
-
"Read the NDJSON traces produced by `fit-harness` with `fit-trace` — grounded-theory method and worked examples.",
|
|
369
|
-
},
|
|
370
|
-
{
|
|
371
|
-
title: "Agent Teams",
|
|
372
|
-
url: "https://www.forwardimpact.team/docs/products/agent-teams/index.md",
|
|
373
|
-
description:
|
|
374
|
-
"How to author the profiles consumed by --agent-profile, --lead-profile, and --agent-profiles.",
|
|
375
|
-
},
|
|
376
|
-
],
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
const runtime = createDefaultRuntime();
|
|
380
|
-
const logger = createLogger("eval", runtime);
|
|
381
|
-
|
|
382
|
-
async function main() {
|
|
383
|
-
const cli = createCli(definition, {
|
|
384
|
-
runtime,
|
|
385
|
-
packageJsonUrl: new URL("../package.json", import.meta.url),
|
|
386
|
-
});
|
|
387
|
-
const parsed = cli.parse(runtime.proc.argv.slice(2));
|
|
388
|
-
if (!parsed) return runtime.proc.exit(0);
|
|
389
|
-
|
|
390
|
-
const { positionals } = parsed;
|
|
391
|
-
if (positionals.length === 0) {
|
|
392
|
-
cli.usageError("no command specified");
|
|
393
|
-
return runtime.proc.exit(2);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const command = positionals[0];
|
|
397
|
-
if (!definition.commands.some((c) => c.name === command)) {
|
|
398
|
-
cli.usageError(`unknown command "${command}"`);
|
|
399
|
-
return runtime.proc.exit(2);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const result = await cli.dispatch(parsed, { deps: { runtime } });
|
|
403
|
-
const envelope = result ?? { ok: true };
|
|
404
|
-
if (!envelope.ok && envelope.error) cli.error(envelope.error);
|
|
405
|
-
runtime.proc.exit(envelope.ok ? 0 : (envelope.code ?? 1));
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
main().catch((error) => {
|
|
409
|
-
logger.exception("main", error);
|
|
410
|
-
createCli(definition, { runtime }).error(error.message);
|
|
411
|
-
process.exit(1);
|
|
412
|
-
});
|
package/bin/fit-selfedit.js
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* fit-selfedit — write stdin to a path that .claude/settings.json
|
|
4
|
-
* permits Edit on, while on a non-main git branch. See
|
|
5
|
-
* libraries/libharness/README.md § fit-selfedit for the full rationale.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import "@forwardimpact/libpreflight/node22";
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { parseArgs } from "node:util";
|
|
11
|
-
import { resolve, relative, dirname } from "node:path";
|
|
12
|
-
import { execFileSync } from "node:child_process";
|
|
13
|
-
|
|
14
|
-
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
|
|
15
|
-
import { minimatch } from "minimatch";
|
|
16
|
-
|
|
17
|
-
const HELP = `fit-selfedit — write stdin to a settings.json-allowed path on a non-main branch.
|
|
18
|
-
|
|
19
|
-
Usage:
|
|
20
|
-
echo content | fit-selfedit <path>
|
|
21
|
-
fit-selfedit <path> < input.txt
|
|
22
|
-
|
|
23
|
-
Safeguards (checked in order):
|
|
24
|
-
1. The nearest .claude/settings.json must contain an Edit(<glob>) rule
|
|
25
|
-
in permissions.allow[] that resolves to the target path.
|
|
26
|
-
2. HEAD must not be detached and the current branch must not be 'main'.
|
|
27
|
-
|
|
28
|
-
Exit codes:
|
|
29
|
-
0 wrote the file
|
|
30
|
-
2 safeguard violation (no settings.json, no matching Edit rule, on
|
|
31
|
-
main, detached HEAD, missing parent directory, TTY stdin)
|
|
32
|
-
1 unexpected I/O error
|
|
33
|
-
|
|
34
|
-
Why this exists:
|
|
35
|
-
Some session harnesses block Edit/Write (and interactive bash writes)
|
|
36
|
-
on .claude/skills/**, even when the project allowlist permits them.
|
|
37
|
-
This CLI is a narrow, audited bypass: a subprocess write that still
|
|
38
|
-
has to clear the project allowlist and the normal merge gates.
|
|
39
|
-
`;
|
|
40
|
-
|
|
41
|
-
function fail(message) {
|
|
42
|
-
process.stderr.write(`fit-selfedit: ${message}\n`);
|
|
43
|
-
process.exit(2);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const { values, positionals } = parseArgs({
|
|
47
|
-
options: {
|
|
48
|
-
help: { type: "boolean", short: "h" },
|
|
49
|
-
version: { type: "boolean" },
|
|
50
|
-
},
|
|
51
|
-
allowPositionals: true,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
if (values.help) {
|
|
55
|
-
process.stdout.write(HELP);
|
|
56
|
-
process.exit(0);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (values.version) {
|
|
60
|
-
const pkg = JSON.parse(
|
|
61
|
-
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
62
|
-
);
|
|
63
|
-
process.stdout.write(`${pkg.version}\n`);
|
|
64
|
-
process.exit(0);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const [targetArg, ...extra] = positionals;
|
|
68
|
-
if (!targetArg) fail("missing <path> (try --help)");
|
|
69
|
-
if (extra.length > 0) fail(`unexpected extra arguments: ${extra.join(" ")}`);
|
|
70
|
-
|
|
71
|
-
const absoluteTarget = resolve(process.cwd(), targetArg);
|
|
72
|
-
|
|
73
|
-
// Safeguard 1: settings.json must grant Edit() on this path. The bin is the
|
|
74
|
-
// sole construction site for the runtime; resolve the finder off the bag
|
|
75
|
-
// rather than constructing a Finder here (Success Criterion 9).
|
|
76
|
-
const runtime = createDefaultRuntime();
|
|
77
|
-
const settingsPath = runtime.finder.findUpward(
|
|
78
|
-
dirname(absoluteTarget),
|
|
79
|
-
".claude/settings.json",
|
|
80
|
-
20,
|
|
81
|
-
);
|
|
82
|
-
if (!settingsPath) {
|
|
83
|
-
fail(
|
|
84
|
-
`no .claude/settings.json found walking upward from ${dirname(absoluteTarget)}`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const projectRoot = dirname(dirname(settingsPath));
|
|
89
|
-
const relativeTarget = relative(projectRoot, absoluteTarget);
|
|
90
|
-
|
|
91
|
-
let settings;
|
|
92
|
-
try {
|
|
93
|
-
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
94
|
-
} catch (err) {
|
|
95
|
-
fail(`failed to parse ${settingsPath}: ${err.message}`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const allowRules = settings?.permissions?.allow;
|
|
99
|
-
if (!Array.isArray(allowRules)) {
|
|
100
|
-
fail(`${settingsPath} has no permissions.allow[] array`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const editPatterns = allowRules
|
|
104
|
-
.filter((rule) => typeof rule === "string")
|
|
105
|
-
.map((rule) => rule.match(/^Edit\((.+)\)$/)?.[1])
|
|
106
|
-
.filter(Boolean);
|
|
107
|
-
|
|
108
|
-
if (editPatterns.length === 0) {
|
|
109
|
-
fail(`${settingsPath} has no Edit() rules in permissions.allow[]`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const matchedPattern = editPatterns.find((pattern) =>
|
|
113
|
-
minimatch(relativeTarget, pattern, { dot: true }),
|
|
114
|
-
);
|
|
115
|
-
if (!matchedPattern) {
|
|
116
|
-
fail(
|
|
117
|
-
`no Edit() rule in ${relative(projectRoot, settingsPath)} matches '${relativeTarget}' ` +
|
|
118
|
-
`(tried: ${editPatterns.map((p) => `Edit(${p})`).join(", ")})`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Safeguard 2: branch must not be main and HEAD must not be detached.
|
|
123
|
-
let branch;
|
|
124
|
-
try {
|
|
125
|
-
branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
126
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
127
|
-
encoding: "utf8",
|
|
128
|
-
}).trim();
|
|
129
|
-
} catch {
|
|
130
|
-
fail("failed to read current git branch (not inside a git repository?)");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (branch === "HEAD") {
|
|
134
|
-
fail("HEAD is detached — refusing (check out a non-main branch first)");
|
|
135
|
-
}
|
|
136
|
-
if (branch === "main") {
|
|
137
|
-
fail("refusing to write while on branch 'main' — switch to a feature branch");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const parent = dirname(absoluteTarget);
|
|
141
|
-
if (!existsSync(parent)) {
|
|
142
|
-
fail(`parent directory '${relative(projectRoot, parent)}' does not exist`);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (process.stdin.isTTY) {
|
|
146
|
-
fail(
|
|
147
|
-
"stdin is a TTY — pipe content in (e.g. `echo … | fit-selfedit <path>`)",
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const chunks = [];
|
|
152
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
153
|
-
const content = Buffer.concat(chunks);
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
writeFileSync(absoluteTarget, content);
|
|
157
|
-
} catch (err) {
|
|
158
|
-
process.stderr.write(`fit-selfedit: write failed: ${err.message}\n`);
|
|
159
|
-
process.exit(1);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
process.stderr.write(
|
|
163
|
-
`fit-selfedit: wrote ${content.length} byte${content.length === 1 ? "" : "s"} to ${relativeTarget} ` +
|
|
164
|
-
`(matched Edit(${matchedPattern}), branch ${branch})\n`,
|
|
165
|
-
);
|