@webappwiz/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/changed.d.ts +30 -0
- package/cost.d.ts +45 -0
- package/index-3k3rtw49.js +128 -0
- package/index-8jpb8xae.js +44 -0
- package/index-g2q5rg7s.js +229 -0
- package/index-jbrs6gqt.js +418 -0
- package/index.d.ts +2 -0
- package/index.js +110 -0
- package/judge.d.ts +99 -0
- package/judge.js +8 -0
- package/mode.d.ts +23 -0
- package/package.json +53 -0
- package/report.d.ts +85 -0
- package/rules.d.ts +20 -0
- package/rules.js +8 -0
- package/signoff.d.ts +63 -0
- package/signoff.js +7 -0
- package/skills/add.d.ts +7 -0
- package/skills/ls.d.ts +7 -0
- package/skills/skill.d.ts +36 -0
- package/skills/update.d.ts +7 -0
- package/table.d.ts +5 -0
- package/update.d.ts +22 -0
- package/webappwiz.d.ts +15 -0
- package/webappwiz.js +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jared Johnson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @webappwiz/cli
|
|
2
|
+
|
|
3
|
+
Keeps a project in step with a webappwiz release.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bunx @webappwiz/cli update # pin webappwiz deps, like bun update
|
|
7
|
+
bunx @webappwiz/cli skills ls # what there is, and what you have
|
|
8
|
+
bunx @webappwiz/cli skills add arbor # install an agent skill
|
|
9
|
+
bunx @webappwiz/cli skills update # refresh the ones already installed
|
|
10
|
+
bunx @webappwiz/cli rules ls # every rule there is
|
|
11
|
+
bunx @webappwiz/cli judge . # check a directory against them
|
|
12
|
+
bunx @webappwiz/cli signoff # does this change need a person?
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## rules
|
|
16
|
+
|
|
17
|
+
Every rule webappwiz judges itself by is named in [`rules.ts`](./rules.ts), as
|
|
18
|
+
`JUDGE_RULES` and `SIGNOFF_RULES`, off the classes
|
|
19
|
+
[`@webappwiz/rules`](../rules/rules) ships. There is no config file and no
|
|
20
|
+
preset: a rule is in one of those lists or it does not exist.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
ID RULE SET LEVEL FILES
|
|
24
|
+
no-em-dashes No em dashes judge error **/*.ts
|
|
25
|
+
one-class-per-file One class per file judge error **/*.ts
|
|
26
|
+
visual-work-tested Visual work is tested signoff
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`rules show <id>` prints one in full: its glob, its level, and the document an
|
|
30
|
+
agent is handed verbatim.
|
|
31
|
+
|
|
32
|
+
The `SET` column is which of the two lists a rule is in. `judge` rules are what
|
|
33
|
+
`judge` checks files against, file by file. `signoff` rules have no glob and no
|
|
34
|
+
level because they are about a change rather than a file: `signoff` weighs them,
|
|
35
|
+
and what they answer is whether it needs a person rather than where the code is
|
|
36
|
+
wrong.
|
|
37
|
+
|
|
38
|
+
## signoff
|
|
39
|
+
|
|
40
|
+
Weighs a change against the signoff rules and exits 1 with a reason when one of
|
|
41
|
+
them wants a person to look before it merges. One agent call over the whole
|
|
42
|
+
diff, since that is what these rules are about.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bunx @webappwiz/cli signoff # everything since main
|
|
46
|
+
bunx @webappwiz/cli signoff --since HEAD~3 # measured against another ref
|
|
47
|
+
bunx @webappwiz/cli signoff --print # the rules, to apply yourself
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`--print` is the cheapest signoff there is: the agent about to merge reads the
|
|
51
|
+
rules and weighs its own change, spawning nothing. A project points its agent
|
|
52
|
+
instructions at that rather than at a list of rule ids, which goes stale the
|
|
53
|
+
next time a rule is added.
|
|
54
|
+
|
|
55
|
+
## judge
|
|
56
|
+
|
|
57
|
+
Runs the rules over a directory, one agent call per set of rules sharing a set
|
|
58
|
+
of files.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bunx @webappwiz/cli judge . --agent haiku
|
|
62
|
+
bunx @webappwiz/cli judge . --estimate # what would this read, and cost
|
|
63
|
+
bunx @webappwiz/cli judge . --print # print the prompts, spawn nothing
|
|
64
|
+
bunx @webappwiz/cli judge . --since main # only what changed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Each rule's code half runs first, free, and only what it escalates reaches an
|
|
68
|
+
agent. `--budget` caps what a run may read before it asks whether you meant it;
|
|
69
|
+
`--estimate` answers that without having to guess a budget low enough to be
|
|
70
|
+
refused. `--print`, `--estimate` and running are three things to do with one
|
|
71
|
+
plan, so passing two of them is an error rather than one quietly winning. Code excuses itself from a rule with a `rule-ignore <id>: <reason>`
|
|
72
|
+
comment above the line, or `rule-ignore-file <id>: <reason>` for the file.
|
|
73
|
+
|
|
74
|
+
## update
|
|
75
|
+
|
|
76
|
+
Walks a directory for every `package.json` (workspaces, nested apps, anything)
|
|
77
|
+
and rewrites each webappwiz dependency to one version. They are released
|
|
78
|
+
together, so a project running two of them at different versions is running a
|
|
79
|
+
combination nobody tested.
|
|
80
|
+
|
|
81
|
+
The default version is this package's own, which is the point of `bunx`: the
|
|
82
|
+
release you invoke is the release you get. `--version` pins something else.
|
|
83
|
+
`workspace:` ranges are left alone; inside a monorepo they already track each
|
|
84
|
+
other.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bunx @webappwiz/cli update ./apps --version 1.4.0
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## skills
|
|
91
|
+
|
|
92
|
+
Puts the agent skills bundled with this package into `<dir>/.agents/skills/`.
|
|
93
|
+
Each skill's frontmatter carries the version it came from, so a stale copy is
|
|
94
|
+
visible rather than merely wrong.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
bunx @webappwiz/cli skills ls ./project
|
|
98
|
+
bunx @webappwiz/cli skills add arbor ./project
|
|
99
|
+
bunx @webappwiz/cli skills update ./project
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
SKILL SHIPS INSTALLED
|
|
104
|
+
arbor 1.4.0 1.3.0
|
|
105
|
+
other 1.4.0 -
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`add` installs one skill by name. `update` refreshes the ones a project already
|
|
109
|
+
has and never installs a new one: which skills a project uses is its own
|
|
110
|
+
business, and a skill nobody chose should not arrive by way of an update. `ls`
|
|
111
|
+
answers the one thing neither can: which version a project is actually holding.
|
|
112
|
+
|
|
113
|
+
Both replace what is there; local edits to a synced skill do not survive, and
|
|
114
|
+
are not meant to.
|
package/changed.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Ps } from "webappwiz/system";
|
|
2
|
+
/**
|
|
3
|
+
* The files git says are new or changed in `dir` since `ref`, named the way a
|
|
4
|
+
* rule's glob is: relative to `dir`, so a run over one package of a repo sees
|
|
5
|
+
* its own paths rather than the repo's.
|
|
6
|
+
*
|
|
7
|
+
* Deletions are left out. A violation quotes the offending line from disk, so a
|
|
8
|
+
* file that is gone has nothing to read and nothing to report.
|
|
9
|
+
*/
|
|
10
|
+
export interface ChangedOptions {
|
|
11
|
+
/** What git is spawned through; the real process by default. */
|
|
12
|
+
ps?: Ps;
|
|
13
|
+
}
|
|
14
|
+
export declare function changed(dir: string, ref: string, opts?: ChangedOptions): Promise<Set<string>>;
|
|
15
|
+
/**
|
|
16
|
+
* The change itself, as a patch: everything in `dir` that differs from `ref`,
|
|
17
|
+
* committed or not, and the paths of the files git has never been told about,
|
|
18
|
+
* which no diff reaches.
|
|
19
|
+
*
|
|
20
|
+
* The new files are named rather than shown because a reader of this patch is
|
|
21
|
+
* an agent with the working directory in front of it, and it can open them.
|
|
22
|
+
*/
|
|
23
|
+
export interface DiffOptions {
|
|
24
|
+
/** What git is spawned through; the real process by default. */
|
|
25
|
+
ps?: Ps;
|
|
26
|
+
}
|
|
27
|
+
export declare function diff(dir: string, ref: string, opts?: DiffOptions): Promise<{
|
|
28
|
+
patch: string;
|
|
29
|
+
added: string[];
|
|
30
|
+
}>;
|
package/cost.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Fs } from "webappwiz/system";
|
|
2
|
+
/** The agents an estimate can price, which is every one `--agent` accepts. */
|
|
3
|
+
export declare const priced: () => string[];
|
|
4
|
+
/**
|
|
5
|
+
* What those tokens cost to read on that agent, or undefined for an agent with
|
|
6
|
+
* no published price, which is any `--exec` command.
|
|
7
|
+
*
|
|
8
|
+
* A floor twice over: it counts input only, and only the input a plan can see.
|
|
9
|
+
*/
|
|
10
|
+
export declare function floor(agent: string, tokens: number): number | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Dollars a single call costs beyond the files it was given, per agent.
|
|
13
|
+
*
|
|
14
|
+
* Everything the floor cannot see is charged per call, not per byte: the agent
|
|
15
|
+
* pays for its own system prompt every time it is spawned, and on this project
|
|
16
|
+
* that is most of a small call's bill. Measuring it per call is what lets a
|
|
17
|
+
* figure taken from a two-call run stand up to a fifteen-call one.
|
|
18
|
+
*/
|
|
19
|
+
export type Overheads = Record<string, number>;
|
|
20
|
+
/**
|
|
21
|
+
* What past runs in `root` measured, empty until one has finished there. A
|
|
22
|
+
* missing or unreadable file is empty rather than an error: a calibration is a
|
|
23
|
+
* convenience, and losing it costs the caller a worse estimate, not a run.
|
|
24
|
+
*/
|
|
25
|
+
export interface OverheadsOptions {
|
|
26
|
+
/** What the record is read through; the real filesystem by default. */
|
|
27
|
+
fs?: Fs;
|
|
28
|
+
}
|
|
29
|
+
export declare function overheads(dir: string, opts?: OverheadsOptions): Promise<Overheads>;
|
|
30
|
+
/**
|
|
31
|
+
* Records what a call on this agent costs over its floor, so the next
|
|
32
|
+
* `--estimate` has something better than one. Other agents are left alone,
|
|
33
|
+
* since each is a separate measurement.
|
|
34
|
+
*/
|
|
35
|
+
export interface CalibrateOptions {
|
|
36
|
+
/** What the record is written through; the real filesystem by default. */
|
|
37
|
+
fs?: Fs;
|
|
38
|
+
}
|
|
39
|
+
export declare function calibrate(dir: string, agent: string, call: number, opts?: CalibrateOptions): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* What a plan costs on one agent: the price of the files it names, plus what
|
|
42
|
+
* every call charges on top. Undefined for an agent with no listed price, and
|
|
43
|
+
* the floor alone until a run has measured one.
|
|
44
|
+
*/
|
|
45
|
+
export declare function predict(agent: string, tokens: number, calls: number, measured: Overheads): number | undefined;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ask,
|
|
3
|
+
count,
|
|
4
|
+
diff,
|
|
5
|
+
divider,
|
|
6
|
+
mode,
|
|
7
|
+
overBudget,
|
|
8
|
+
tokens,
|
|
9
|
+
usd
|
|
10
|
+
} from "./index-jbrs6gqt.js";
|
|
11
|
+
|
|
12
|
+
// signoff.ts
|
|
13
|
+
import {
|
|
14
|
+
agentCommand,
|
|
15
|
+
Harness,
|
|
16
|
+
prompt as reviewPrompt
|
|
17
|
+
} from "@webappwiz/rules";
|
|
18
|
+
import { ConsoleLogger, color } from "webappwiz/log";
|
|
19
|
+
import { NodePs } from "webappwiz/system";
|
|
20
|
+
import { SystemClock } from "webappwiz/time";
|
|
21
|
+
class Signoff {
|
|
22
|
+
rules;
|
|
23
|
+
defaultAgent;
|
|
24
|
+
confirmer;
|
|
25
|
+
log;
|
|
26
|
+
ps;
|
|
27
|
+
clock;
|
|
28
|
+
constructor(rules, defaultAgent, opts = {}) {
|
|
29
|
+
this.rules = rules;
|
|
30
|
+
this.defaultAgent = defaultAgent;
|
|
31
|
+
this.confirmer = opts.confirmer ?? ask;
|
|
32
|
+
this.log = opts.log ?? new ConsoleLogger;
|
|
33
|
+
this.ps = opts.ps ?? new NodePs;
|
|
34
|
+
this.clock = opts.clock ?? new SystemClock;
|
|
35
|
+
}
|
|
36
|
+
async run(opts) {
|
|
37
|
+
if (mode(opts) === "print") {
|
|
38
|
+
this.print();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const dir = opts.dir.replace(/\/+$/, "") || "/";
|
|
42
|
+
const { patch, added } = await diff(dir, opts.since, { ps: this.ps });
|
|
43
|
+
if (patch === "" && added.length === 0) {
|
|
44
|
+
this.log.info(`nothing has changed since ${opts.since}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const agent = agentCommand(opts.exec === undefined ? { agent: opts.agent ?? this.defaultAgent } : opts);
|
|
48
|
+
const review = this.review(patch, added, opts.since);
|
|
49
|
+
const predicted = tokens(review.bytes ?? 0);
|
|
50
|
+
this.log.info(`weighing ${opts.since}..working tree against ` + `${count(this.rules.length, "rule")}, reading ` + `${predicted}+ tokens with ${agent.label}`);
|
|
51
|
+
if (predicted > opts.budget) {
|
|
52
|
+
this.log.info(overBudget(predicted, opts.budget));
|
|
53
|
+
if (!await this.confirmer.confirm("Run anyway?")) {
|
|
54
|
+
throw new Error("over budget");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this.say(await this.judge(review, agent, dir));
|
|
58
|
+
}
|
|
59
|
+
async judge(review, agent, dir) {
|
|
60
|
+
const harness = new Harness({
|
|
61
|
+
log: this.log,
|
|
62
|
+
ps: this.ps,
|
|
63
|
+
clock: this.clock
|
|
64
|
+
});
|
|
65
|
+
harness.events.on("finished", ({ took, cost }) => {
|
|
66
|
+
const spent = cost === undefined ? "" : ` ${usd(cost)}`;
|
|
67
|
+
this.log.info(color.gray(`read in ${took.human()}${spent}`));
|
|
68
|
+
});
|
|
69
|
+
return await harness.run([review], agent, { cwd: dir });
|
|
70
|
+
}
|
|
71
|
+
say(findings) {
|
|
72
|
+
if (findings.length === 0) {
|
|
73
|
+
this.log.info(color.green("✓ nothing in this change needs a person"));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const finding of findings) {
|
|
77
|
+
const where = finding.file === undefined ? "" : `${color.bold(finding.file + (finding.line === undefined ? "" : `:${finding.line}`))} `;
|
|
78
|
+
this.log.info(` ${where}${finding.message} ${color.gray(`(${finding.rule})`)}`);
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`${count(findings.length, "reason")} to escalate rather than merge`);
|
|
81
|
+
}
|
|
82
|
+
review(patch, added, ref) {
|
|
83
|
+
const draft = {
|
|
84
|
+
rules: this.rules,
|
|
85
|
+
label: "signoff",
|
|
86
|
+
context: [
|
|
87
|
+
`The change, as \`git diff ${ref}\` prints it:`,
|
|
88
|
+
`\`\`\`diff
|
|
89
|
+
${patch}
|
|
90
|
+
\`\`\``,
|
|
91
|
+
...added.length === 0 ? [] : [
|
|
92
|
+
"These files are new and are in no diff yet. Read each one from " + "your working directory:",
|
|
93
|
+
added.map((file) => `- ${file}`).join(`
|
|
94
|
+
`)
|
|
95
|
+
]
|
|
96
|
+
].join(`
|
|
97
|
+
|
|
98
|
+
`),
|
|
99
|
+
instructions: WEIGHING
|
|
100
|
+
};
|
|
101
|
+
return { ...draft, bytes: Buffer.byteLength(reviewPrompt(draft)) };
|
|
102
|
+
}
|
|
103
|
+
print() {
|
|
104
|
+
if (this.rules.length === 0) {
|
|
105
|
+
this.log.info("no signoff rules");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.log.info("Weigh your change against each rule below. Anything that needs " + "review goes to a person instead of trunk.");
|
|
109
|
+
for (const rule of this.rules) {
|
|
110
|
+
this.log.info(`
|
|
111
|
+
${divider(rule.id)}
|
|
112
|
+
`);
|
|
113
|
+
this.log.info(rule.document.trim());
|
|
114
|
+
}
|
|
115
|
+
this.log.info(`
|
|
116
|
+
${divider()}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
var WEIGHING = [
|
|
120
|
+
"These rules decide one thing: whether this change can merge on its own, " + "or needs a person to look at it first.",
|
|
121
|
+
"",
|
|
122
|
+
"Each rule's `Ships` section is what merges with nobody looking, and its " + "`Needs review` section is what does not. Report a violation only for " + "what the `Needs review` side covers, and only where the change " + "actually does it.",
|
|
123
|
+
"",
|
|
124
|
+
"These rules are about the change as a whole, so leave `file` and `line` " + "out unless one place in the change is the whole of the answer."
|
|
125
|
+
].join(`
|
|
126
|
+
`);
|
|
127
|
+
|
|
128
|
+
export { Signoff };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// rules.ts
|
|
2
|
+
import { defineRules } from "@webappwiz/rules";
|
|
3
|
+
import {
|
|
4
|
+
ClassesOverFunctionExports,
|
|
5
|
+
CommentsSayWhyNotWhat,
|
|
6
|
+
DevServersFindAPort,
|
|
7
|
+
DocCommentsAddressUsers,
|
|
8
|
+
FakesOverMocks,
|
|
9
|
+
NamedOptionsLast,
|
|
10
|
+
NoEmDashes,
|
|
11
|
+
ObjectsOverCallbacks,
|
|
12
|
+
OneClassPerFile,
|
|
13
|
+
OneDirPerInterface,
|
|
14
|
+
ParametersDeclareFields,
|
|
15
|
+
ReactiveOverUseState,
|
|
16
|
+
ResourcesAreDisposable,
|
|
17
|
+
SimpleTestSetup,
|
|
18
|
+
TestsNotWeakened,
|
|
19
|
+
VisualWorkTested
|
|
20
|
+
} from "@webappwiz/rules/catalog";
|
|
21
|
+
var JUDGE_RULES = defineRules({
|
|
22
|
+
rules: [
|
|
23
|
+
new NoEmDashes,
|
|
24
|
+
new OneClassPerFile,
|
|
25
|
+
new ParametersDeclareFields,
|
|
26
|
+
new ClassesOverFunctionExports,
|
|
27
|
+
new ObjectsOverCallbacks,
|
|
28
|
+
new NamedOptionsLast,
|
|
29
|
+
new SimpleTestSetup,
|
|
30
|
+
new FakesOverMocks,
|
|
31
|
+
new CommentsSayWhyNotWhat,
|
|
32
|
+
new DocCommentsAddressUsers,
|
|
33
|
+
new OneDirPerInterface,
|
|
34
|
+
new DevServersFindAPort,
|
|
35
|
+
new ReactiveOverUseState,
|
|
36
|
+
new ResourcesAreDisposable
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
var SIGNOFF_RULES = [
|
|
40
|
+
new TestsNotWeakened,
|
|
41
|
+
new VisualWorkTested
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export { JUDGE_RULES, SIGNOFF_RULES };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
table
|
|
3
|
+
} from "./index-jbrs6gqt.js";
|
|
4
|
+
// package.json
|
|
5
|
+
var version = "0.0.1";
|
|
6
|
+
|
|
7
|
+
// skills/add.ts
|
|
8
|
+
import { ConsoleLogger } from "webappwiz/log";
|
|
9
|
+
import { NodeFs } from "webappwiz/system";
|
|
10
|
+
|
|
11
|
+
// skills/skill.ts
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
// templates/arbor.skill.md
|
|
15
|
+
var arbor_skill_default = `---
|
|
16
|
+
name: arbor
|
|
17
|
+
description: Use the @webappwiz/arbor CLI to land your work on trunk, or a base branch given as an argument, from an isolated git worktree without pull requests. Read this before making any code change in an arbor repository, since it decides where the work happens, and whenever you need to add, claim, merge, remove, list, show, locate, or escalate a task.
|
|
18
|
+
argument-hint: "[base-branch]"
|
|
19
|
+
version: 0.0.0
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Using arbor
|
|
23
|
+
|
|
24
|
+
\`arbor\` runs many agents on one repo, each in its own git worktree, landing on
|
|
25
|
+
trunk without pull requests. Run it with \`bunx @webappwiz/arbor <command>\` (or
|
|
26
|
+
\`arbor\` if on PATH). \`arbor --help\` explains the commands; this file covers
|
|
27
|
+
only what the CLI cannot tell you.
|
|
28
|
+
|
|
29
|
+
**Rule:** never use raw git for state transitions arbor covers. Every landing
|
|
30
|
+
goes through \`arbor merge\`. The only exception is finishing an in-progress
|
|
31
|
+
rebase (\`git add\`, \`git rebase --continue\`), then merging again.
|
|
32
|
+
|
|
33
|
+
A failed command prints \`{reason}\` JSON on stdout and instructions on stderr:
|
|
34
|
+
do what stderr says. The one case to memorize is exit 4 \`lease_lost\`: stop,
|
|
35
|
+
do not retry, another agent owns the tree.
|
|
36
|
+
|
|
37
|
+
## Before you start
|
|
38
|
+
|
|
39
|
+
Other agents may already be working. Before creating anything, list the files
|
|
40
|
+
you expect to touch, then \`arbor ls\`, and for each task in flight compare with
|
|
41
|
+
its changed files:
|
|
42
|
+
\`git -C "$(arbor path <task>)" diff --name-only main...task/<task>\`
|
|
43
|
+
(\`arbor show <task>\` for its plan; neither takes its lease).
|
|
44
|
+
|
|
45
|
+
If nothing overlaps, carry on. If something does, wait for it to land rather
|
|
46
|
+
than buying a rebase conflict: say one line about what you are waiting on,
|
|
47
|
+
then re-check periodically. A status is only true for the moment you read it,
|
|
48
|
+
so re-run \`arbor ls\` every time you are about to repeat one. Act on what it
|
|
49
|
+
becomes:
|
|
50
|
+
|
|
51
|
+
- gone from \`arbor ls\`: it landed. Redo the overlap check (trunk moved) and
|
|
52
|
+
carry on.
|
|
53
|
+
- \`escalated\`: your work is blocked on a person too. Tell the human what it
|
|
54
|
+
is blocked on and wait.
|
|
55
|
+
- \`orphaned\`, \`stray\`, \`unrecorded\` or \`unknown\` with nobody driving it: that
|
|
56
|
+
tree is broken. Say so and ask. (A tree mid-merge can read as \`orphaned\`
|
|
57
|
+
for a moment, so trust a broken status only if it survives a second look.)
|
|
58
|
+
- still \`working\` or \`merging\` after however long the human would tolerate
|
|
59
|
+
hearing nothing: offer the choice of waiting longer, working alongside, or
|
|
60
|
+
picking up something else, and say what you have not started.
|
|
61
|
+
|
|
62
|
+
A \`stale\` lease on a \`working\` task is normal (arbor only heartbeats while a
|
|
63
|
+
command runs): when waiting, watch a task's status, never its lease.
|
|
64
|
+
|
|
65
|
+
## Workflow
|
|
66
|
+
|
|
67
|
+
1. \`arbor add <task>\`, or \`arbor claim <task>\` to resume one. When this skill
|
|
68
|
+
is invoked with a branch argument (\`/arbor feature/auth\`), or the user
|
|
69
|
+
names the branch the work should land on, pass it as \`--base\` to every
|
|
70
|
+
task you create for that request. Otherwise omit \`--base\`; never guess a
|
|
71
|
+
base from the currently checked-out branch.
|
|
72
|
+
2. Fill in the \`ARBOR.md\` stub \`add\` wrote at the worktree root (see below).
|
|
73
|
+
3. Do the work; commit with git (arbor never commits for you).
|
|
74
|
+
4. \`arbor merge\`. On failure, do what stderr says and merge again.
|
|
75
|
+
|
|
76
|
+
A successful merge deletes the worktree, and your working directory with it:
|
|
77
|
+
\`cd\` to the main tree (merge prints its path) before running anything else.
|
|
78
|
+
Then report it:
|
|
79
|
+
|
|
80
|
+
\`\`\`\`markdown
|
|
81
|
+
### ✅ Merged \`<task>\` onto \`<base>\`
|
|
82
|
+
|
|
83
|
+
One sentence blending what the task set out to do with what actually changed.
|
|
84
|
+
\`\`\`\`
|
|
85
|
+
|
|
86
|
+
✅ for a merge, ❌ for a task you \`arbor rm\`ed instead. Anything else worth
|
|
87
|
+
saying goes after this block, not instead of it.
|
|
88
|
+
|
|
89
|
+
## ARBOR.md
|
|
90
|
+
|
|
91
|
+
Your session can die at any moment; \`ARBOR.md\` is what lets a stranger
|
|
92
|
+
\`arbor claim\` the task and continue. Fill in \`## Goal\` (one or two lines on
|
|
93
|
+
what done means) and list every step you can foresee under \`## Next\` as
|
|
94
|
+
\`- [ ]\` items, roughly one commit each. Move items to \`## Done\` as you finish
|
|
95
|
+
them: those checkboxes are the only progress the task reports. Decisions,
|
|
96
|
+
dead ends and how to verify go under \`## Notes\`. Update it as you go; a stale
|
|
97
|
+
plan is worse than none.
|
|
98
|
+
|
|
99
|
+
\`arbor show <task>\` prints the file and every way it departs from the
|
|
100
|
+
expected shape; run it on your own task after writing the file. \`add\` excludes
|
|
101
|
+
\`ARBOR.md\` from git for you: never commit it, and never mention it in a commit
|
|
102
|
+
message.
|
|
103
|
+
|
|
104
|
+
## Committing
|
|
105
|
+
|
|
106
|
+
Plain, human-style commit messages with **no attribution**: no
|
|
107
|
+
\`Co-authored-by:\` trailers, no "Generated with", no agent or model names, no
|
|
108
|
+
\`--author\` overrides. Commit as often as it helps you; a task usually takes
|
|
109
|
+
fewer than 5 commits, and wanting many more means the task wants splitting,
|
|
110
|
+
not squashing.
|
|
111
|
+
|
|
112
|
+
## Escalation
|
|
113
|
+
|
|
114
|
+
Merge only work you verified yourself. Escalate instead when verification
|
|
115
|
+
needs a person: external services, destructive migrations, anything tests
|
|
116
|
+
cannot confirm. And if the user asked to see the work before it lands,
|
|
117
|
+
escalate regardless.
|
|
118
|
+
|
|
119
|
+
1. \`arbor escalate <reason>\`.
|
|
120
|
+
2. Under \`## Blocked\` in \`ARBOR.md\`, state what needs verifying, ending in a
|
|
121
|
+
question a yes/no or a sentence can answer.
|
|
122
|
+
3. Leave something the human can look at and print its **absolute path**
|
|
123
|
+
(start from \`arbor path <task>\`). For anything visual or UX, that means a
|
|
124
|
+
screenshot; if producing one is expensive or has side effects, ask before
|
|
125
|
+
starting and say what it will cost.
|
|
126
|
+
|
|
127
|
+
If you claim a tree whose \`## Blocked\` question is unanswered, do not resume
|
|
128
|
+
or merge: ask the user and wait for the answer.
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
// skills/skill.ts
|
|
132
|
+
var bundled = { arbor: arbor_skill_default };
|
|
133
|
+
function versionOf(md) {
|
|
134
|
+
const frontmatter = md.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
|
|
135
|
+
return frontmatter.match(/^version:\s*(.+)$/m)?.[1]?.trim() ?? null;
|
|
136
|
+
}
|
|
137
|
+
function available(skills) {
|
|
138
|
+
return Object.entries(skills).toSorted(([left], [right]) => left.localeCompare(right));
|
|
139
|
+
}
|
|
140
|
+
async function copy(name, doc, dir, opts) {
|
|
141
|
+
const target = `${dir}/.agents/skills/${name}/SKILL.md`;
|
|
142
|
+
await opts.fs.mkdir(dirname(target));
|
|
143
|
+
await opts.fs.write(target, doc);
|
|
144
|
+
opts.log.info(`wrote ${target}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// skills/add.ts
|
|
148
|
+
async function add(opts) {
|
|
149
|
+
const log = opts.log ?? new ConsoleLogger;
|
|
150
|
+
const fs = opts.fs ?? new NodeFs;
|
|
151
|
+
const skills = opts.skills ?? bundled;
|
|
152
|
+
const doc = skills[opts.skill];
|
|
153
|
+
if (doc === undefined) {
|
|
154
|
+
const have = available(skills).map(([name]) => name);
|
|
155
|
+
throw new Error(`no such skill: ${opts.skill} (have ${have.join(", ")})`);
|
|
156
|
+
}
|
|
157
|
+
await copy(opts.skill, doc, opts.dir, { log, fs });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// skills/ls.ts
|
|
161
|
+
import { ConsoleLogger as ConsoleLogger2, color } from "webappwiz/log";
|
|
162
|
+
import { NodeFs as NodeFs2 } from "webappwiz/system";
|
|
163
|
+
async function ls(opts) {
|
|
164
|
+
const log = opts.log ?? new ConsoleLogger2;
|
|
165
|
+
const fs = opts.fs ?? new NodeFs2;
|
|
166
|
+
const skills = opts.skills ?? bundled;
|
|
167
|
+
const rows = [["skill", "ships", "installed"].map(color.dim)];
|
|
168
|
+
let stale = 0;
|
|
169
|
+
for (const [name, doc] of available(skills)) {
|
|
170
|
+
const ships = versionOf(doc) ?? "?";
|
|
171
|
+
const installed = await fs.read(`${opts.dir}/.agents/skills/${name}/SKILL.md`).then(versionOf).catch(() => null);
|
|
172
|
+
if (installed !== null && installed !== ships) {
|
|
173
|
+
stale++;
|
|
174
|
+
}
|
|
175
|
+
rows.push([name, ships, installed ?? "-"]);
|
|
176
|
+
}
|
|
177
|
+
const lines = table(rows);
|
|
178
|
+
if (stale > 0) {
|
|
179
|
+
lines.push("", `${stale} out of date: run \`skills update\``);
|
|
180
|
+
}
|
|
181
|
+
log.info(lines.join(`
|
|
182
|
+
`));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// skills/update.ts
|
|
186
|
+
import { ConsoleLogger as ConsoleLogger3 } from "webappwiz/log";
|
|
187
|
+
import { NodeFs as NodeFs3 } from "webappwiz/system";
|
|
188
|
+
async function update(opts) {
|
|
189
|
+
const log = opts.log ?? new ConsoleLogger3;
|
|
190
|
+
const fs = opts.fs ?? new NodeFs3;
|
|
191
|
+
const installed = await fs.readdir(`${opts.dir}/.agents/skills`).catch(() => []);
|
|
192
|
+
const skills = opts.skills ?? bundled;
|
|
193
|
+
const ours = available(skills).filter(([name]) => installed.includes(name));
|
|
194
|
+
if (ours.length === 0) {
|
|
195
|
+
log.info(`no webappwiz skills in ${opts.dir}: add one with \`skills add\``);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
for (const [name, doc] of ours) {
|
|
199
|
+
await copy(name, doc, opts.dir, { log, fs });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// update.ts
|
|
204
|
+
import { basename } from "node:path";
|
|
205
|
+
import { ConsoleLogger as ConsoleLogger4 } from "webappwiz/log";
|
|
206
|
+
import { NodeFs as NodeFs4, walk } from "webappwiz/system";
|
|
207
|
+
var DEPENDENCY = /("(?:webappwiz|@webappwiz\/[^"]+)"\s*:\s*")(?!workspace:)[^"]*(")/g;
|
|
208
|
+
async function update2(opts) {
|
|
209
|
+
const log = opts.log ?? new ConsoleLogger4;
|
|
210
|
+
const fs = opts.fs ?? new NodeFs4;
|
|
211
|
+
let count = 0;
|
|
212
|
+
for await (const path of walk(opts.dir, { fs })) {
|
|
213
|
+
if (basename(path) !== "package.json") {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const before = await fs.read(path);
|
|
217
|
+
const after = before.replace(DEPENDENCY, `$1${opts.version}$2`);
|
|
218
|
+
if (after === before) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
await fs.write(path, after);
|
|
222
|
+
log.info(`updated ${path}`);
|
|
223
|
+
count++;
|
|
224
|
+
}
|
|
225
|
+
log.info(`${count} package.json pinned to ${opts.version}`);
|
|
226
|
+
await update({ dir: opts.dir, log, fs, skills: opts.skills });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export { version, add, ls, update, update2 as update1 };
|