@nanocollective/roster 0.1.0-alpha.20 → 0.1.0-alpha.22

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 CHANGED
@@ -33,6 +33,11 @@ first finished run, most of it writing what the business is and the staff member
33
33
  Everything the portal does is also a command, on the same files. Nothing changes anything
34
34
  without `--apply`.
35
35
 
36
+ **`roster` here means either of these.** With nothing installed, run commands through npx:
37
+ `npx @nanocollective/roster@latest upgrade`. Or install it once with
38
+ `npm install -g @nanocollective/roster` and type `roster upgrade`. When you run through npx,
39
+ the commands roster suggests are printed the npx way, so they can be pasted as they are.
40
+
36
41
  ```bash
37
42
  roster init --org acme # ops repo, org layer, merge base
38
43
  roster hire cto # scaffold a staff member: repo, workflows, labels, peers
package/dist/cli.js CHANGED
@@ -5401,6 +5401,30 @@ function short3(e) {
5401
5401
  return line.length > 200 ? `${line.slice(0, 199)}\u2026` : line;
5402
5402
  }
5403
5403
 
5404
+ // src/lib/bin.ts
5405
+ var NPX = "npx @nanocollective/roster@latest";
5406
+ function bin(env = process.env, argv1 = process.argv[1] ?? "") {
5407
+ if (env.ROSTER_BIN) return env.ROSTER_BIN;
5408
+ if (env.npm_command === "exec" || /[\\/]_npx[\\/]/.test(argv1)) return NPX;
5409
+ return "roster";
5410
+ }
5411
+ var COMMAND = /\broster(?= (?:init|hire|app|credential|run|doctor|fix|portal|prompt|upgrade|retire|lint|brief|export|help)\b)/g;
5412
+ function withBin(text, name = bin()) {
5413
+ return name === "roster" ? text : text.replace(COMMAND, name);
5414
+ }
5415
+ function rewriteOutput(name = bin()) {
5416
+ const raw = process.stdout.write.bind(process.stdout);
5417
+ if (name === "roster") return (text) => void raw(text);
5418
+ for (const stream of [process.stdout, process.stderr]) {
5419
+ const write = stream.write.bind(stream);
5420
+ stream.write = ((chunk, ...rest) => write(
5421
+ typeof chunk === "string" ? withBin(chunk, name) : chunk,
5422
+ ...rest
5423
+ ));
5424
+ }
5425
+ return (text) => void raw(text);
5426
+ }
5427
+
5404
5428
  // src/lib/browser.ts
5405
5429
  import { spawn } from "child_process";
5406
5430
  function shouldOpen(env, isTTY, noOpen) {
@@ -6415,7 +6439,7 @@ async function portalCommand(argv) {
6415
6439
  const handleSetup = async (url, req, res) => {
6416
6440
  const route = url.pathname.slice("/api/setup/".length);
6417
6441
  if (route === "status") {
6418
- json(res, { ...await setupStatus(startedIn), startedIn });
6442
+ json(res, { ...await setupStatus(startedIn), startedIn }, true);
6419
6443
  return;
6420
6444
  }
6421
6445
  if (route === "app") {
@@ -6796,7 +6820,7 @@ async function portalCommand(argv) {
6796
6820
  return;
6797
6821
  }
6798
6822
  if (url.pathname === "/api/doctor") {
6799
- collect({ ops: ws?.opsDir, offline: url.searchParams.get("offline") === "1" }).then((report3) => json(res, report3 ?? { findings: [], online: false, empty: true })).catch((err) => {
6823
+ collect({ ops: ws?.opsDir, offline: url.searchParams.get("offline") === "1" }).then((report3) => json(res, report3 ?? { findings: [], online: false, empty: true }, true)).catch((err) => {
6800
6824
  res.writeHead(200, { "content-type": "application/json" });
6801
6825
  res.end(JSON.stringify({ findings: [], error: String(err?.message ?? err) }));
6802
6826
  });
@@ -6877,7 +6901,7 @@ async function portalCommand(argv) {
6877
6901
  throw new Error(`"${handle}" is not in org.yaml`);
6878
6902
  }
6879
6903
  const plan3 = buildRetirePlan(w, org, handle, parseYaml);
6880
- json(res, { action, plan: plan3 });
6904
+ json(res, { action, plan: plan3 }, true);
6881
6905
  return;
6882
6906
  }
6883
6907
  if (!/^[a-z][a-z0-9-]{1,20}$/.test(handle)) {
@@ -6889,10 +6913,11 @@ async function portalCommand(argv) {
6889
6913
  const flags = hireFlags(url.searchParams);
6890
6914
  const plan2 = buildPlan(w, org, handle, flags, parseYaml);
6891
6915
  void readOrgSecret(org.org, plan2.staff.agentSecret).then(
6892
- (orgSecret) => json(res, {
6893
- action,
6894
- plan: { ...plan2, orgSecret, files: [...plan2.files.keys()].sort() }
6895
- })
6916
+ (orgSecret) => json(
6917
+ res,
6918
+ { action, plan: { ...plan2, orgSecret, files: [...plan2.files.keys()].sort() } },
6919
+ true
6920
+ )
6896
6921
  );
6897
6922
  } catch (err) {
6898
6923
  res.writeHead(400, { "content-type": "application/json" });
@@ -6966,7 +6991,7 @@ async function portalCommand(argv) {
6966
6991
  return;
6967
6992
  }
6968
6993
  if (url.pathname === "/api/fix") {
6969
- gather(w, url.searchParams.get("offline") === "1").then((items) => json(res, { items, text: fixBrief(w, items) })).catch((err) => {
6994
+ gather(w, url.searchParams.get("offline") === "1").then((items) => json(res, { items, text: fixBrief(w, items) }, true)).catch((err) => {
6970
6995
  res.writeHead(200, { "content-type": "application/json" });
6971
6996
  res.end(JSON.stringify({ items: [], error: String(err?.message ?? err) }));
6972
6997
  });
@@ -7491,12 +7516,13 @@ function refuseWrite(res) {
7491
7516
  res.writeHead(403, { "content-type": "application/json" });
7492
7517
  res.end(JSON.stringify({ error: "write actions need a local POST from the portal" }));
7493
7518
  }
7494
- function json(res, data) {
7519
+ function json(res, data, advice = false) {
7495
7520
  res.writeHead(200, {
7496
7521
  "content-type": "application/json; charset=utf-8",
7497
7522
  "cache-control": "no-store"
7498
7523
  });
7499
- res.end(JSON.stringify(data));
7524
+ const text = JSON.stringify(data);
7525
+ res.end(advice ? withBin(text) : text);
7500
7526
  }
7501
7527
  function hireFlags(params) {
7502
7528
  const num = (k) => params.get(k) ? Number(params.get(k)) : void 0;
@@ -7900,11 +7926,24 @@ var HELPS = {
7900
7926
  export: exportHelp
7901
7927
  };
7902
7928
  async function main(argv) {
7929
+ const raw = rewriteOutput();
7903
7930
  const [command, ...rest] = argv;
7904
7931
  if (!command) return portalCommand([]);
7905
7932
  if (command === "help" || command === "--help" || command === "-h") {
7906
7933
  const topic = rest[0];
7907
- process.stdout.write(topic && HELPS[topic] ? HELPS[topic] : HELP);
7934
+ if (!topic || !HELPS[topic]) {
7935
+ const name = bin();
7936
+ if (name !== "roster") {
7937
+ raw(
7938
+ `
7939
+ Run these as ${name} <command>, or install it: npm install -g @nanocollective/roster
7940
+ `
7941
+ );
7942
+ }
7943
+ raw(HELP);
7944
+ return 0;
7945
+ }
7946
+ process.stdout.write(HELPS[topic]);
7908
7947
  return 0;
7909
7948
  }
7910
7949
  const run6 = COMMANDS[command];
package/docs/commands.md CHANGED
@@ -6,6 +6,11 @@ sidebar_order: 8
6
6
 
7
7
  # Commands
8
8
 
9
+ **`roster` here means either of these.** With nothing installed, run commands through npx:
10
+ `npx @nanocollective/roster@latest upgrade`. Or install it once with
11
+ `npm install -g @nanocollective/roster` and type `roster upgrade`. When you run through npx,
12
+ the commands roster suggests are printed the npx way, so they can be pasted as they are.
13
+
9
14
  Every command that changes anything prints a plan and changes nothing unless you pass
10
15
  `--apply`. `lint`, `prompt`, `export`, `brief`, `doctor` and `fix` never change anything.
11
16
  `portal` is the exception: it is interactive, and each change there is a button you press after
@@ -144,6 +144,9 @@ member on their next run.
144
144
 
145
145
  The same road, command by command. Each prints its plan and changes nothing without `--apply`.
146
146
 
147
+ `roster` below means `npx @nanocollective/roster@latest`, unless you have installed it with
148
+ `npm install -g @nanocollective/roster`.
149
+
147
150
  ```bash
148
151
  roster init --org acme --apply # the ops repo, and its Actions access
149
152
  roster brief discover # a brief for org/business.md; write priorities.md too
package/docs/upgrading.md CHANGED
@@ -10,9 +10,9 @@ The framework writes templates out. A tenant runs its own copies. So the two dri
10
10
  `roster upgrade` is what reconciles them without eating your edits.
11
11
 
12
12
  ```bash
13
- roster upgrade # what would change
14
- roster upgrade --apply # do it
15
- roster upgrade --check # exit non-zero if anything is pending (for CI)
13
+ npx @nanocollective/roster@latest upgrade # what would change
14
+ npx @nanocollective/roster@latest upgrade --apply # do it
15
+ npx @nanocollective/roster@latest upgrade --check # exit non-zero if anything is pending (for CI)
16
16
  ```
17
17
 
18
18
  ## How it decides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.20",
3
+ "version": "0.1.0-alpha.22",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -148,3 +148,13 @@
148
148
  .mode:hover{color:var(--ink)}
149
149
  .mode.on{background:var(--bg-elev);color:var(--ink);box-shadow:var(--shadow-sm)}
150
150
  .modes + div > .paste{border-top:0;margin-top:0;padding-top:0}
151
+
152
+ /* ---------- a checked paste ---------- */
153
+ .verdict{margin:0 0 10px;font:600 13.5px var(--sans)}
154
+ .verdict.ok{color:var(--accent)}
155
+ .verdict.bad{color:var(--err)}
156
+ .pastepreview{border:1px solid var(--line);border-radius:var(--r);padding:14px 16px;margin-bottom:12px;background:var(--bg)}
157
+ .pastenotes{margin:0 0 12px}
158
+ .pastenotes summary{cursor:pointer;font:600 12.5px var(--sans);color:var(--ink-dim);margin-bottom:6px}
159
+ .pastenotes .md{border-left:3px solid var(--line);padding-left:12px}
160
+ .pastefile .diff{white-space:pre-wrap;overflow-wrap:anywhere}
@@ -17,6 +17,10 @@ const BUSINESS_QUESTIONS = [
17
17
 
18
18
  const val = (input) => String(input.value ?? "").trim();
19
19
 
20
+ /* A file still in the shape it shipped in holds the questions, not answers, and pre-filling the
21
+ fields with "Not a segment. The person…" reads as if somebody had already answered them. */
22
+ const isStub = (text) => /This file is a stub|Or answer these by hand/.test(text);
23
+
20
24
  /**
21
25
  * org/business.md as five questions. With `text`, the answers already in the file fill the
22
26
  * fields, so changing one answer does not mean retyping the other four.
@@ -25,7 +29,7 @@ const val = (input) => String(input.value ?? "").trim();
25
29
  */
26
30
  export function businessForm(o) {
27
31
  const form = el("div");
28
- const known = sections(o.text ?? "");
32
+ const known = isStub(o.text ?? "") ? new Map() : sections(o.text ?? "");
29
33
  const fields = BUSINESS_QUESTIONS.map(([id, label, heading, required]) => {
30
34
  const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
31
35
  const had = known.get(heading.toLowerCase());
@@ -64,7 +68,7 @@ export function businessForm(o) {
64
68
  /** org/priorities.md as three lines and an out-of-scope list, filled from the file if it has them. */
65
69
  export function prioritiesForm(o) {
66
70
  const form = el("div");
67
- const { items, outs } = parsePriorities(o.text ?? "");
71
+ const { items, outs } = isStub(o.text ?? "") ? { items: [], outs: [] } : parsePriorities(o.text ?? "");
68
72
  const ranks = [1, 2, 3].map((i) => {
69
73
  const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
70
74
  if (items[i - 1]) input.value = items[i - 1];
@@ -7,6 +7,7 @@
7
7
  * Nothing here writes. The panel parses, shows a diff, and waits for a second click. */
8
8
 
9
9
  import { getBrief, parsePaste, saveFile } from "../api.js";
10
+ import { mdlite } from "../md.js";
10
11
  import { el, toClipboard } from "../dom.js";
11
12
  import { unifiedDiff, diffStat } from "../textdiff.js";
12
13
 
@@ -123,23 +124,47 @@ export function paste(opts) {
123
124
  result.append(note);
124
125
  }
125
126
 
127
+ // What the AI said around the file: its caveats and "check these" are worth reading first.
128
+ const notes = String(answer.value ?? "")
129
+ .replace(/<<<ROSTER FILE [^>]+>>>[\s\S]*?<<<ROSTER END>>>/g, "")
130
+ .trim();
131
+
126
132
  for (const file of data.files ?? []) {
127
- if (file.unchanged) continue;
128
133
  const card = el("div", { className: "pastefile" });
129
- const stat = diffStat(file.before, file.text);
134
+ const name = file.path.split("/").slice(-2).join("/");
135
+ if (file.unchanged) {
136
+ card.append(el("p", { className: "verdict", textContent: "✓ Same as what's already in " + name + ". Nothing to save." }));
137
+ result.append(card);
138
+ continue;
139
+ }
130
140
  card.append(
131
- el("b", { textContent: file.path }),
132
- el("span", {
133
- className: "meta",
134
- textContent: `+${stat.added} −${stat.removed}` + (file.writable ? "" : " · not writable"),
141
+ el("p", {
142
+ className: "verdict" + (file.writable ? " ok" : " bad"),
143
+ textContent: file.writable
144
+ ? "✓ Looks good. Saving replaces " + name + " with this:"
145
+ : "✗ " + file.path + " can't be written from here.",
135
146
  }),
136
147
  );
137
- const diff = el("pre", { className: "diff" });
148
+ card.append(el("div", { className: "md doc pastepreview", innerHTML: mdlite(file.text) }));
149
+
150
+ if (notes) {
151
+ const said = el("details", { className: "pastenotes", open: true });
152
+ said.append(el("summary", { textContent: "Your AI also said" }), el("div", { className: "md doc", innerHTML: mdlite(notes) }));
153
+ card.append(said);
154
+ }
155
+
156
+ const diff = el("pre", { className: "diff", hidden: true });
138
157
  diff.textContent = unifiedDiff(file.before, file.text, file.path);
139
- card.append(diff);
158
+ const stat = diffStat(file.before, file.text);
159
+ const toggle = el("button", { className: "ghbtn", textContent: `Show changes (+${stat.added} −${stat.removed})` });
160
+ toggle.onclick = () => {
161
+ diff.hidden = !diff.hidden;
162
+ toggle.textContent = (diff.hidden ? "Show changes" : "Hide changes") + ` (+${stat.added} −${stat.removed})`;
163
+ };
140
164
 
165
+ const row = el("div", { className: "row" });
141
166
  if (file.writable) {
142
- const save = el("button", { className: "btn primary", textContent: "Save and commit" });
167
+ const save = el("button", { className: "btn primary", textContent: "Save" });
143
168
  save.onclick = async () => {
144
169
  save.disabled = true;
145
170
  save.textContent = "Saving…";
@@ -150,17 +175,19 @@ export function paste(opts) {
150
175
  opts.onSaved?.();
151
176
  } catch (err) {
152
177
  save.disabled = false;
153
- save.textContent = "Save and commit";
178
+ save.textContent = "Save";
154
179
  card.append(el("p", { className: "err", textContent: String(err.message || err) }));
155
180
  }
156
181
  };
157
- card.append(save);
182
+ row.append(save);
158
183
  }
184
+ row.append(toggle);
185
+ card.append(row, diff);
159
186
  result.append(card);
160
187
  }
161
188
 
162
189
  if (!(data.problems ?? []).length && !(data.files ?? []).length) {
163
- result.append(el("p", { className: "sub", textContent: "Nothing to do." }));
190
+ result.append(el("p", { className: "verdict bad", textContent: "✗ No file found in that reply." }));
164
191
  }
165
192
  };
166
193
 
@@ -219,13 +219,9 @@ export function viewStaff(m) {
219
219
 
220
220
  box.append(el("div", { className: "planhead", textContent: "Then, on the new card" }));
221
221
  box.append(
222
- line(
223
- "todo",
224
- "GitHub App, or roster app " + s.handle + " --apply: creates it, sets its secrets, and " +
225
- "opens the install page with the repos already ticked",
226
- ),
222
+ line("todo", "Create their GitHub App, then install it on the repos it opens with ticked"),
227
223
  line("todo", "Write the charter"),
228
- line("todo", "Run once now, or roster run " + s.handle + " --apply: the run that proves the wiring"),
224
+ line("todo", "Run once now, to check everything works"),
229
225
  );
230
226
  for (const w of plan.warnings ?? []) box.append(line("warn", S.data.staff.length ? w : portalWords(w)));
231
227