@nanocollective/roster 0.1.0-alpha.23 → 0.1.0-alpha.24

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/dist/cli.js CHANGED
@@ -808,6 +808,11 @@ function exampleSection(choice) {
808
808
  ""
809
809
  ].join("\n");
810
810
  }
811
+ function charterStarter(choice, business) {
812
+ const path = join8(docsDir(), "charters", `${choice}.md`);
813
+ if (!existsSync8(path)) throw new Error(`there is no example charter called "${choice}"`);
814
+ return readFileSync7(path, "utf8").replace(/^> \*\*An example to adapt[\s\S]*?\n\n/m, "").replace(/\bAcme\b/g, business);
815
+ }
811
816
  function exampleOffer(handle) {
812
817
  return [
813
818
  "## Worked examples",
@@ -4260,6 +4265,7 @@ function readRig(root, manifest, commits) {
4260
4265
  return {
4261
4266
  workflows: existsSync17(wfDir) ? readdirSync7(wfDir).filter((f) => /\.ya?ml$/.test(f)).sort() : [],
4262
4267
  hasCharter: existsSync17(join19(root, "CHARTER.md")),
4268
+ charterStub: !existsSync17(join19(root, "CHARTER.md")) || looksUnwritten(readFileSync16(join19(root, "CHARTER.md"), "utf8")),
4263
4269
  hasManifest: existsSync17(join19(root, "staff.yaml")),
4264
4270
  missingSurfaces: declared.filter((s) => s?.path && !existsSync17(join19(root, s.path))).map((s) => s.path),
4265
4271
  memoryBytes: bytesOf(join19(root, "memory", "INDEX.md")),
@@ -6422,6 +6428,7 @@ async function portalCommand(argv) {
6422
6428
  const TTL = 45e3;
6423
6429
  const labelCache = /* @__PURE__ */ new Map();
6424
6430
  let runsCache = null;
6431
+ const progressCache = /* @__PURE__ */ new Map();
6425
6432
  const LABEL_TTL = 3e5;
6426
6433
  const brainDirs = () => (readOrg(ws.opsDir, parseYaml).staff ?? []).map((s) => s.dir ?? s.handle);
6427
6434
  const knownRepos = () => {
@@ -6429,7 +6436,9 @@ async function portalCommand(argv) {
6429
6436
  return (org.repos ?? []).map((r) => ({
6430
6437
  name: r.name,
6431
6438
  owner: org.org,
6432
- role: r.role ?? "repo"
6439
+ role: r.role ?? "repo",
6440
+ // As org.yaml has it. Absent on a hand-written entry, which hire treats as public.
6441
+ visibility: r.visibility
6433
6442
  }));
6434
6443
  };
6435
6444
  const writeAllowed = (req) => {
@@ -6894,6 +6903,40 @@ async function portalCommand(argv) {
6894
6903
  }
6895
6904
  return;
6896
6905
  }
6906
+ if (url.pathname === "/api/charter-example") {
6907
+ const org = readOrg(w.opsDir, parseYaml);
6908
+ const handle = url.searchParams.get("staff") ?? "";
6909
+ const entry = (org.staff ?? []).find((s) => s.handle === handle);
6910
+ const asked = url.searchParams.get("kind") ?? matchExample(handle, entry?.name ?? "");
6911
+ if (!asked || !CHARTER_EXAMPLES.includes(asked)) {
6912
+ json(res, { kind: null, text: "" });
6913
+ return;
6914
+ }
6915
+ json(res, { kind: asked, text: charterStarter(asked, String(org.name ?? org.org)) });
6916
+ return;
6917
+ }
6918
+ if (url.pathname === "/api/staff/progress") {
6919
+ const handle = url.searchParams.get("staff") ?? "";
6920
+ const hit = progressCache.get(handle);
6921
+ if (url.searchParams.get("fresh") !== "1" && hit && Date.now() - hit.at < 6e4) {
6922
+ json(res, hit.body);
6923
+ return;
6924
+ }
6925
+ const org = readOrg(w.opsDir, parseYaml);
6926
+ const entry = (org.staff ?? []).find((s) => s.handle === handle);
6927
+ if (!entry) {
6928
+ res.writeHead(400, { "content-type": "application/json" });
6929
+ res.end(JSON.stringify({ error: `no staff member "${handle}"` }));
6930
+ return;
6931
+ }
6932
+ const dir = entry.dir ?? entry.handle;
6933
+ const spec2 = specFromManifest(readManifest2(join28(w.root, dir), parseYaml), dir);
6934
+ staffProgress(spec2, dailyWorkflow(entry.handle)).then((body2) => {
6935
+ progressCache.set(handle, { at: Date.now(), body: body2 });
6936
+ json(res, body2);
6937
+ }).catch(() => json(res, { app: null, publicApp: null, ran: null }));
6938
+ return;
6939
+ }
6897
6940
  if (url.pathname === "/api/staff/plan") {
6898
6941
  const handle = url.searchParams.get("handle") ?? "";
6899
6942
  const action = url.searchParams.get("action") === "retire" ? "retire" : "hire";
@@ -7009,7 +7052,8 @@ async function portalCommand(argv) {
7009
7052
  parseYaml,
7010
7053
  kind,
7011
7054
  handle,
7012
- url.searchParams.get("example") ?? void 0
7055
+ url.searchParams.get("example") ?? void 0,
7056
+ url.searchParams.get("about") ?? void 0
7013
7057
  );
7014
7058
  json(res, {
7015
7059
  kind,
@@ -7485,7 +7529,7 @@ ${warn}
7485
7529
  });
7486
7530
  });
7487
7531
  }
7488
- function buildPasteBrief(ws, parseYaml, kind, handle, example) {
7532
+ function buildPasteBrief(ws, parseYaml, kind, handle, example, about) {
7489
7533
  if (!pasteable(kind)) throw new Error(`there is no paste-mode brief for "${kind}"`);
7490
7534
  const org = readOrg(ws.opsDir, parseYaml);
7491
7535
  const staff = org.staff ?? [];
@@ -7510,6 +7554,14 @@ function buildPasteBrief(ws, parseYaml, kind, handle, example) {
7510
7554
  let rendered = renderBrief2(briefTemplate(kind), tokens);
7511
7555
  const chosen = kind === "charter" ? example ?? matchExample(handle, tokens.NAME ?? "") ?? "none" : void 0;
7512
7556
  if (chosen) rendered = withExample(rendered, handle, tokens.NAME ?? "", chosen);
7557
+ if (kind === "charter" && about?.trim()) {
7558
+ rendered = `${rendered.trimEnd()}
7559
+
7560
+ ## What the owner said this role does
7561
+
7562
+ ${about.trim()}
7563
+ `;
7564
+ }
7513
7565
  return { ...pasteBrief(ws, kind, rendered, { dir, peers }), example: chosen };
7514
7566
  }
7515
7567
  function renderBrief2(text, tokens) {
@@ -7527,6 +7579,24 @@ function json(res, data, advice = false) {
7527
7579
  const text = JSON.stringify(data);
7528
7580
  res.end(advice ? withBin(text) : text);
7529
7581
  }
7582
+ async function staffProgress(spec2, workflow) {
7583
+ const [repo, shared, runs] = await Promise.all([
7584
+ api(`repos/${spec2.brain}/actions/secrets`),
7585
+ api(`repos/${spec2.brain}/actions/organization-secrets`),
7586
+ api(
7587
+ `repos/${spec2.brain}/actions/workflows/${workflow}/runs?status=success&per_page=1`
7588
+ )
7589
+ ]);
7590
+ const names = repo.ok ? /* @__PURE__ */ new Set([
7591
+ ...repo.data.secrets.map((x) => x.name),
7592
+ ...shared.ok ? shared.data.secrets.map((x) => x.name) : []
7593
+ ]) : null;
7594
+ return {
7595
+ app: names ? names.has(`${spec2.secretPrefix}_APP_ID`) : null,
7596
+ publicApp: names ? names.has(`${spec2.publicSecretPrefix}_APP_ID`) : null,
7597
+ ran: runs.ok ? (runs.data?.total_count ?? 0) > 0 : null
7598
+ };
7599
+ }
7530
7600
  function hireFlags(params) {
7531
7601
  const num = (k) => params.get(k) ? Number(params.get(k)) : void 0;
7532
7602
  return {
package/docs/export.md CHANGED
@@ -99,6 +99,7 @@ Only surfaces that exist on disk appear. A declared surface that is missing show
99
99
  |---|---|
100
100
  | `workflows[]` | filenames under `.github/workflows/` |
101
101
  | `hasCharter`, `hasManifest` | |
102
+ | `charterStub` | `CHARTER.md` is absent or still the scaffold, the same test as doctor's `charter.stub` |
102
103
  | `missingSurfaces[]` | declared, not on disk |
103
104
  | `memoryBytes` | size of `INDEX.md` |
104
105
  | `notesBytes` | total size of `memory/notes/` |
@@ -71,49 +71,61 @@ See [concepts](concepts.md#priorities).
71
71
 
72
72
  ![The Staff screen, with a card per staff member and Hire someone underneath](images/staff.jpg)
73
73
 
74
- **Staff → Hire someone.** Only the handle is required. The plan shows the repo it creates, the
75
- schedule it chose, and the commits it will make **as you** in repos that already exist: each
76
- peer's `staff.yaml`, and `org.yaml`. Nothing is left uncommitted on disk.
74
+ **Staff**, then pick a role: CTO, CMO, Support, or **Something else** with a name and a sentence
75
+ about what they do. With nobody hired yet the Staff screen opens on the picker. The role fills
76
+ in the handle, name and repo; they are under **Advanced** if you want to change them.
77
77
 
78
- For the first hire there is nobody to copy an App name from, so the form asks for two: this
79
- staff member's App, and the shared public App. Names are unique across GitHub, so prefix them
80
- with the org. The public one only matters if a product repo is public; leave it empty when they
81
- are all private.
78
+ The role opens a numbered list: hire, create their GitHub App, write their charter, add your
79
+ agent credential, run once now. The steps after the hire say *Hire first* until it is done.
80
+
81
+ Step 1 says in one sentence what the hire creates and when they run. **Show details** has the
82
+ full plan: the repo, the schedule and why, and the commits it will make **as you** in repos that
83
+ already exist: each peer's `staff.yaml`, and `org.yaml`. Nothing is left uncommitted on disk.
84
+
85
+ For the first hire there is nobody to copy an App name from, so **Advanced** also has two:
86
+ this staff member's App, and the shared public App, filled in as `<org>-<handle>` and
87
+ `<org>-robot`. Names are unique across GitHub, so keep the org prefix. The public one only
88
+ matters if a product repo is public.
82
89
 
83
90
  Product repos come from `org.yaml`. Mark one on the setup screen, or later with **Org → Add a
84
91
  product repo**.
85
92
 
93
+ After **Hire** the list stays on the same staff member, now on step 2. Leave and come back later
94
+ with **Finish setting up** on their card.
95
+
86
96
  ## 4. Create the App, and confirm the install
87
97
 
88
- **GitHub App**, on the new card. GitHub has no API that creates an App, so a tab opens and you
98
+ **Create the App**, in step 2. GitHub has no API that creates an App, so a tab opens and you
89
99
  confirm. The App's id and private key go straight into the repo's secrets and never touch disk.
100
+ The public App is offered too when a product repo is public.
90
101
 
91
102
  Then **Install it**. The install page opens with the organisation and every repo this staff
92
103
  member needs already ticked: its brain, the trackers of its peers, and the product repos. Check
93
104
  the list and confirm. That confirmation is yours by design: installing grants access, and GitHub
94
105
  asks a person.
95
106
 
96
- ## 5. Store the agent credential, once
107
+ ## 5. Write the charter
97
108
 
98
- **Agent credential**, on the card or on the setup screen. Paste the token from `claude
99
- setup-token` (or your agent's key; the box says where to get one). It is stored as one
100
- organisation secret, shared with the brain repos, and each later hire is added to it. It is not
101
- asked for again.
109
+ Step 3. **Let an AI interview you** is the same copy-a-prompt loop as step 2 of this guide, aimed
110
+ at `CHARTER.md`, carrying the org layer, the peers' charters and, where the role matches one, a
111
+ [worked example](writing-a-charter.md#worked-examples) to model the shape on. **Start from the
112
+ template** opens that example in an editor with your business's name in it; edit it so it fits
113
+ before saving. **Edit the file** is the file as it is. roster never generates a charter, because
114
+ a generated one makes exactly the generic agent this whole arrangement exists to avoid.
102
115
 
103
- On GitHub Free an org secret does not reach private repos, so there it goes on each brain repo
104
- instead, and the page says so. It is still one paste now; a later hire needs it once more.
116
+ ## 6. Store the agent credential, once
105
117
 
106
- ## 6. Write the charter
118
+ Step 4, shown only while no credential is stored. It is also on the card and on the setup
119
+ screen. Paste the token from `claude setup-token` (or your agent's key; the box says where to
120
+ get one). It is stored as one organisation secret, shared with the brain repos, and each later
121
+ hire is added to it. It is not asked for again.
107
122
 
108
- **Write the charter**, on the card. The same copy-a-prompt loop as step 2, aimed at
109
- `CHARTER.md`, carrying the org layer, the peers' charters and, where the role matches one, a
110
- [worked example](writing-a-charter.md#worked-examples) to model the shape on. The brief
111
- interviews you; the charter is yours. roster never generates one, because a generated charter
112
- makes exactly the generic agent this whole arrangement exists to avoid.
123
+ On GitHub Free an org secret does not reach private repos, so there it goes on each brain repo
124
+ instead, and the page says so. It is still one paste now; a later hire needs it once more.
113
125
 
114
126
  ## 7. Run it once
115
127
 
116
- **Run once now**, on the card or on **Health**. It starts the daily workflow, follows it, and
128
+ **Run once now**, step 5, also on the card and on **Health**. It starts the daily workflow, follows it, and
117
129
  tells you how it ended, with the log.
118
130
 
119
131
  **A workflow that has never run has proved nothing**: not that the App is installed on the right
package/docs/portal.md CHANGED
@@ -262,21 +262,48 @@ have more than one human, and a card that names one of two reads as the only one
262
262
 
263
263
  Everyone on the roster, and the things you would otherwise do from a terminal.
264
264
 
265
- **Hiring** runs the same `buildPlan` and `applyPlan` that `roster hire` does, on the server.
266
- Only the handle is required; everything else is copied from whoever is already here. The first
267
- hire has nobody to copy App names from, so its form also asks for the App's name and the shared
268
- public App's, which are `--app` and `--public-app`. The public one only matters when a product
269
- repo is public; on private ones the session uses the staff member's own App. You see
270
- the plan first, listing every file, every label, the schedule it chose and why, the commits it
271
- will make as you in repos that already exist (each peer's `staff.yaml`, `org.yaml`), whether the
272
- new brain joins the credential's org secret, and what is left for you. Nothing happens until you
273
- apply. What the terminal would have printed is shown when it finishes.
265
+ **Hiring** starts from a role. With nobody hired the Staff screen opens on the role picker;
266
+ after that it is behind **Hire someone**. The cards are CTO, CMO and Support, each with a line
267
+ on what they do, and **Something else**, which asks for the role's name and a sentence about it.
268
+ A role that is already hired is not offered. Picking one fills in the handle, name and repo
269
+ (`cto`, `cmo`, `support`, or a handle made from the name) and leaves the schedule empty, so the
270
+ server picks a free slot. All four are under **Advanced**, still editable. The first hire has
271
+ nobody to copy App names from, so Advanced also holds the App's name and the shared public
272
+ App's, which are `--app` and `--public-app`, filled in as `<org>-<handle>` and `<org>-robot`.
273
+ The public one only matters when a product repo is public; on private ones the session uses the
274
+ staff member's own App.
275
+
276
+ The role opens a numbered list on the same screen:
277
+
278
+ 1. **Hire.** One sentence from the plan: the repo it creates and when it runs. **Show details**
279
+ has the full plan, from the same `buildPlan` and `applyPlan` that `roster hire` runs on the
280
+ server: every file, every label, the schedule and why, the commits it makes as you in repos
281
+ that already exist (each peer's `staff.yaml`, `org.yaml`), and whether the new brain joins
282
+ the credential's org secret. **Hire** asks before it acts.
283
+ 2. **Create their GitHub App.** The private App, and the shared public one only when a product
284
+ repo in `org.yaml` is public (or has no visibility written, which hire treats as public).
285
+ 3. **Write their charter.** Three tabs: an AI interview (the default), the matching worked
286
+ example with your business's name in place of Acme's, and the file itself. A role that
287
+ matches no example has no template tab.
288
+ 4. **Add your agent credential.** Only while none is stored.
289
+ 5. **Run once now.**
290
+
291
+ Steps 2 to 5 say *Hire first* until the hire is done. Each step's Done comes from real data:
292
+ the hire from the staff member appearing in `org.yaml`, the charter from `CHARTER.md` no longer
293
+ being the stub (the same test as doctor's `charter.stub`), the App from its `_APP_ID` secret on
294
+ the brain repo, the credential from the org secret, and the run from a successful daily run.
295
+ After **Hire** the screen reloads the org and stays on the same staff member, on step 2.
296
+
297
+ A card whose setup is not finished (a stub charter, no App secrets, or no successful run yet)
298
+ shows **Finish setting up**, which opens the same list for them. The App secrets and the run
299
+ are read from GitHub, so offline only the charter counts.
274
300
 
275
301
  **Writing the charter** is the copy-a-prompt loop below, aimed at `CHARTER.md`. `hire`
276
302
  deliberately does not write it, because a generated charter produces exactly the generic agent
277
303
  this whole arrangement exists to avoid. It is the same brief as `roster brief charter
278
304
  <handle>`, with somewhere to put the answer, and a picker for the worked example it carries as a
279
- model: matched to the role, or another, or none.
305
+ model: matched to the role, or another, or none. For a role added with **Something else**, the
306
+ sentence you typed goes into the brief.
280
307
 
281
308
  **The GitHub App** is `roster app`, on this server rather than a second one. There is no API that
282
309
  creates an App: the only route is the manifest flow, where you post a manifest to a settings page,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.23",
3
+ "version": "0.1.0-alpha.24",
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": {
@@ -137,6 +137,30 @@
137
137
  .todohint{margin:8px 0 0 35px}
138
138
  .todobody{margin:12px 0 0 35px}
139
139
  .todobody .row{margin-top:10px}
140
+ .todo.locked{background:transparent;border-style:dashed}
141
+ .todo.locked h3{color:var(--ink-dim)}
142
+ .todo.locked .todopill{background:none;border:1px solid var(--line);color:var(--ink-faint)}
143
+
144
+ /* ---------- hiring on the Staff screen ---------- */
145
+
146
+ .hirepane > h3{margin:0 0 10px;font:600 14.5px var(--sans)}
147
+ .roles{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;max-width:920px}
148
+ .role{display:grid;gap:5px;align-content:start;text-align:left;cursor:pointer;
149
+ background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);padding:14px 16px;
150
+ color:var(--ink);font:inherit;
151
+ transition:border-color .15s var(--ease),box-shadow .15s var(--ease),transform .15s var(--ease)}
152
+ .role:hover{border-color:var(--accent-dim);box-shadow:var(--shadow-sm);transform:translateY(-1px)}
153
+ .role:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
154
+ .role b{font:600 14px var(--sans);letter-spacing:-.01em}
155
+ .role span{color:var(--ink-dim);font:12.5px/1.5 var(--sans)}
156
+ .roleform{max-width:560px;margin:14px 0 0}
157
+ .roleform textarea{width:100%}
158
+ .hireflow{max-width:920px}
159
+ .hireflowhead{justify-content:space-between;margin:0 0 12px}
160
+ .hireflowhead h2{margin:0;font:600 17px var(--sans);letter-spacing:-.015em}
161
+ .hiresummary{margin:0;font:13.5px/1.55 var(--sans);color:var(--ink)}
162
+ .hiresummary.err{color:var(--err)}
163
+ .hireadvanced{margin-top:12px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--r);max-width:560px}
140
164
  .qfield{display:grid;gap:5px;margin:0 0 12px}
141
165
  .qfield span{font:600 12.5px var(--sans)}
142
166
  .qfield input{background:var(--bg-elev);border:1px solid var(--border-strong);color:var(--ink);
@@ -86,13 +86,22 @@ export const planTenant = (params) =>
86
86
  export const createTenant = (params) => post(params, "/api/setup/apply");
87
87
 
88
88
  /** The copyable prompt, with every file it refers to carried inside it. */
89
- export const getBrief = (kind, staff, example) =>
89
+ export const getBrief = (kind, staff, example, about) =>
90
90
  json(
91
91
  "/api/brief?kind=" + encodeURIComponent(kind) +
92
92
  (staff ? "&staff=" + encodeURIComponent(staff) : "") +
93
- (example ? "&example=" + encodeURIComponent(example) : ""),
93
+ (example ? "&example=" + encodeURIComponent(example) : "") +
94
+ (about ? "&about=" + encodeURIComponent(about) : ""),
94
95
  );
95
96
 
97
+ /** The worked charter that matches a staff member, with the business's name in it. */
98
+ export const getCharterExample = (staff) =>
99
+ json("/api/charter-example?staff=" + encodeURIComponent(staff));
100
+
101
+ /** Whether a staff member's App secrets are set and a daily run has succeeded. Online only. */
102
+ export const getStaffProgress = (staff, fresh) =>
103
+ json("/api/staff/progress?staff=" + encodeURIComponent(staff) + (fresh ? "&fresh=1" : ""));
104
+
96
105
  /** Parse what came back from the model. Reads only: saving is a second, deliberate step. */
97
106
  export const parsePaste = (kind, staff, answer) =>
98
107
  fetch("/api/paste", {
@@ -111,6 +111,7 @@ export async function boot() {
111
111
 
112
112
  S.data = first;
113
113
  S.loadedAt = new Date();
114
+ S.staffOpen = null; // an open hire list survives a refresh, not a page load
114
115
  S.staffHandle = S.data.staff[0]?.handle ?? null;
115
116
  $("#orgname").textContent = S.data.name + " · " + S.data.staff.length + " staff";
116
117
 
@@ -36,6 +36,8 @@ export const S = {
36
36
  runs: null,
37
37
  sync: null,
38
38
  loadedAt: null,
39
+ /** The role whose hire list is open on the Staff screen, kept across a refresh. */
40
+ staffOpen: null,
39
41
 
40
42
  staffHandle: null,
41
43
  /* What is waiting on you, not whose brain you read last. The org-wide screens are where a