@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.4
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 +848 -164
- package/docs/README.md +10 -5
- package/docs/agents.md +320 -9
- package/docs/commands.md +5 -6
- package/docs/concepts.md +27 -9
- package/docs/cost.md +3 -2
- package/docs/doctor-codes.md +13 -4
- package/docs/export.md +2 -1
- package/docs/extending.md +11 -2
- package/docs/getting-started.md +89 -84
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +36 -13
- package/docs/memory.md +9 -6
- package/docs/org-yaml.md +37 -9
- package/docs/portal.md +197 -31
- package/docs/prompts.md +50 -11
- package/docs/security.md +19 -7
- package/docs/session-workflow.md +8 -10
- package/docs/staff-yaml.md +3 -5
- package/docs/troubleshooting.md +17 -14
- package/docs/writing-a-charter.md +18 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
- package/templates/brain/staff.yaml +0 -1
- package/templates/ops/.github/workflows/session.yaml +9 -26
- package/templates/ops/agents.mjs +121 -6
- package/templates/ops/compose.mjs +61 -4
- package/templates/ops/org/operating.md +0 -6
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/mention.md +16 -2
- package/templates/portal/css/base.css +122 -8
- package/templates/portal/css/brain.css +8 -1
- package/templates/portal/css/diff.css +6 -2
- package/templates/portal/css/health.css +21 -2
- package/templates/portal/css/inbox.css +93 -5
- package/templates/portal/css/layout.css +26 -4
- package/templates/portal/css/markdown.css +23 -3
- package/templates/portal/css/setup.css +11 -6
- package/templates/portal/index.html +7 -1
- package/templates/portal/js/api.js +33 -0
- package/templates/portal/js/app.js +33 -7
- package/templates/portal/js/dialog.js +47 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +8 -1
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +47 -5
- package/templates/portal/js/views/checklist.js +20 -7
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/health.js +163 -35
- package/templates/portal/js/views/inbox.js +882 -96
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +142 -62
- package/templates/portal/js/views/prompt.js +50 -63
- package/templates/portal/js/views/setup.js +37 -13
- package/templates/portal/js/views/staff.js +62 -2
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- package/templates/ops/prompts/pr-mention.md +0 -57
|
@@ -13,6 +13,19 @@ import { inline } from "../md.js";
|
|
|
13
13
|
import { S } from "../state.js";
|
|
14
14
|
import { matchesFact } from "./brain.js";
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Which of the three kinds of provenance a tag is, as a class the stylesheet knows.
|
|
18
|
+
*
|
|
19
|
+
* A fact is measured, derived, or ruled on by a human — and the third one is written with that
|
|
20
|
+
* human's own marker, `[will]` or `[sam]` or whatever the org chose. The class used to be the
|
|
21
|
+
* marker itself against a stylesheet that knew one word, so every org but the one this was
|
|
22
|
+
* written in lost the "a person decided this" colour, which is the one that matters most.
|
|
23
|
+
*/
|
|
24
|
+
export function provenanceKind(provenance) {
|
|
25
|
+
const word = String(provenance).toLowerCase();
|
|
26
|
+
return word === "measured" || word === "derived" ? word : "ruled";
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
export function showMemory(viewer, s, key, pick) {
|
|
17
30
|
const section = key.startsWith("mem:") ? key.slice(4) : null;
|
|
18
31
|
const slug = key.startsWith("fact:") ? key.slice(5) : null;
|
|
@@ -61,7 +74,9 @@ export function showMemory(viewer, s, key, pick) {
|
|
|
61
74
|
d.innerHTML =
|
|
62
75
|
'<span class="slug" data-goto="' + esc(f.slug) + '" title="Show just this fact">' +
|
|
63
76
|
esc(f.slug) + "</span>" +
|
|
64
|
-
(f.provenance
|
|
77
|
+
(f.provenance
|
|
78
|
+
? '<span class="tag ' + provenanceKind(f.provenance) + '">' + esc(f.provenance) + "</span>"
|
|
79
|
+
: "") +
|
|
65
80
|
(f.note ? '<span class="notelink" data-note="' + esc(f.note) + '">' + esc(f.note) + "</span>" : "") +
|
|
66
81
|
'<p class="stmt">' + inline(f.statement) + "</p>" +
|
|
67
82
|
(f.consequence
|
|
@@ -1,27 +1,47 @@
|
|
|
1
1
|
/* The org layer, in one place.
|
|
2
2
|
*
|
|
3
|
-
* These
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* These files are what every staff member inherits. Editing `org/voice.md` once is the whole
|
|
4
|
+
* extensibility story — the thing this arrangement exists to make possible — and until recently
|
|
5
|
+
* you reached them through a staff member's Prompt screen, which is a strange route to a file
|
|
6
|
+
* that belongs to nobody in particular.
|
|
7
|
+
*
|
|
8
|
+
* The list comes off disk rather than out of this file. It used to be five paths written here,
|
|
9
|
+
* which meant a tenant that added `org/pricing.md` could not open it at all, and one that had
|
|
10
|
+
* not written `org/business.md` yet got "not found" with nothing to do about it. Both are the
|
|
11
|
+
* same mistake: a list of what a tenant *should* have, standing in for what it has.
|
|
7
12
|
*
|
|
8
13
|
* `org.yaml` is here too, and is the one file that stops every prompt composing when it is
|
|
9
14
|
* wrong. The server validates it before writing.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
import { getFile, post } from "../api.js";
|
|
13
|
-
import { el, esc, grow, kb } from "../dom.js";
|
|
17
|
+
import { getFile, getOrgLayer, post } from "../api.js";
|
|
18
|
+
import { el, esc, grow, kb, skeleton } from "../dom.js";
|
|
19
|
+
import { icon } from "../icons.js";
|
|
14
20
|
import { mdlite } from "../md.js";
|
|
15
|
-
import { S, writeHash } from "../state.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
import { humansOf, S, writeHash } from "../state.js";
|
|
22
|
+
import { yamlPre } from "../yaml.js";
|
|
23
|
+
|
|
24
|
+
/* What the files roster itself ships are for, in one line each. A filename says nothing about
|
|
25
|
+
which one to open; anything a tenant has added of its own falls back to its first heading. */
|
|
26
|
+
const KNOWN = {
|
|
27
|
+
"org.yaml": "Who and what: the humans, the repos, the defaults, which agent runs them.",
|
|
28
|
+
"org/business.md":
|
|
29
|
+
"What the business actually is. Every prompt is composed on top of it, and it is the file that stops the agents writing generic slop.",
|
|
30
|
+
"org/operating.md":
|
|
31
|
+
"The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.",
|
|
32
|
+
"org/guardrails.md":
|
|
33
|
+
"The non-negotiables. Every staff member is bound by these and cannot argue with them.",
|
|
34
|
+
"org/voice.md":
|
|
35
|
+
"How everything they write reads. Change it once, and every surface changes on the next run.",
|
|
36
|
+
"prompts/daily.md": "The whole of a scheduled run, before the fragments are pulled into it.",
|
|
37
|
+
"prompts/mention.md": "What they are sent when you @-mention them.",
|
|
38
|
+
"prompts/_identity.md": "Who they post as, and what wakes you.",
|
|
39
|
+
"prompts/_paths.md": "Where everything is in the runner's checkout.",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const GROUPS = [
|
|
43
|
+
["org", "The org layer", "Inherited by everybody, on their next run."],
|
|
44
|
+
["prompts", "The prompts", "The runs themselves. Edit these last: they are the framework's."],
|
|
25
45
|
];
|
|
26
46
|
|
|
27
47
|
export function viewOrg(m) {
|
|
@@ -37,10 +57,20 @@ export function viewOrg(m) {
|
|
|
37
57
|
);
|
|
38
58
|
|
|
39
59
|
const facts = el("div", { className: "grid", style: "margin-bottom:18px" });
|
|
40
|
-
const
|
|
60
|
+
const humans = humansOf();
|
|
41
61
|
facts.append(
|
|
42
62
|
kv("Organisation", esc(org.org), org.name),
|
|
43
|
-
|
|
63
|
+
/* Plural on purpose. An org can answer to more than one person, and a card that names one
|
|
64
|
+
of two is worse than one that names neither: it reads as the only one who counts. */
|
|
65
|
+
kv(
|
|
66
|
+
"They answer to",
|
|
67
|
+
humans.length
|
|
68
|
+
? humans.map((h) => esc(h.name ?? h.github)).join(", ")
|
|
69
|
+
: "nobody",
|
|
70
|
+
humans.length
|
|
71
|
+
? humans.map((h) => (h.github ? "@" + h.github : "no github handle")).join(" · ")
|
|
72
|
+
: "org.yaml names no human, so nothing can wake an agent",
|
|
73
|
+
),
|
|
44
74
|
kv("Staff", String(org.staff.length), org.staff.map((s) => s.handle).join(", ")),
|
|
45
75
|
);
|
|
46
76
|
m.append(facts);
|
|
@@ -51,30 +81,53 @@ export function viewOrg(m) {
|
|
|
51
81
|
split.append(tree, viewer);
|
|
52
82
|
m.append(split);
|
|
53
83
|
|
|
54
|
-
const group = el("div", { className: "navgroup" });
|
|
55
|
-
group.append(el("div", { className: "ghead", textContent: "The org layer" }));
|
|
56
|
-
for (const [path, why] of DOCS) {
|
|
57
|
-
const b = el("button", { className: "tfile tlayer", title: why });
|
|
58
|
-
b.dataset.key = path;
|
|
59
|
-
const t = el("span", { className: "t" });
|
|
60
|
-
t.append(
|
|
61
|
-
el("span", { className: "lname", textContent: path.replace(/^org\//, "") }),
|
|
62
|
-
el("span", { className: "lrepo", textContent: why }),
|
|
63
|
-
);
|
|
64
|
-
b.append(t);
|
|
65
|
-
b.onclick = () => open(path);
|
|
66
|
-
group.append(b);
|
|
67
|
-
}
|
|
68
|
-
tree.append(group);
|
|
69
|
-
|
|
70
|
-
/* Declared before the first `open()`, not after: `const` is not hoisted, and putting it
|
|
71
|
-
below the call made the first paint fail with "cannot access 'full' before
|
|
72
|
-
initialization" rather than rendering anything. */
|
|
73
84
|
const full = (path) => S.data.opsName + "/" + path;
|
|
74
85
|
|
|
75
|
-
|
|
86
|
+
tree.replaceChildren(...skeleton("row", 6));
|
|
87
|
+
getOrgLayer()
|
|
88
|
+
.then(({ files }) => {
|
|
89
|
+
S.orgFiles = files ?? [];
|
|
90
|
+
paintTree();
|
|
91
|
+
const first = S.orgFiles[0]?.path;
|
|
92
|
+
open(S.orgFiles.some((f) => f.path === S.orgOpen) ? S.orgOpen : first);
|
|
93
|
+
})
|
|
94
|
+
.catch((e) => {
|
|
95
|
+
tree.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function why(file) {
|
|
99
|
+
return KNOWN[file.path] ?? file.title ?? "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function paintTree() {
|
|
103
|
+
tree.replaceChildren();
|
|
104
|
+
if (!S.orgFiles.length) {
|
|
105
|
+
tree.append(el("p", { className: "empty", textContent: "No org files here." }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const [id, heading, note] of GROUPS) {
|
|
109
|
+
const mine = S.orgFiles.filter((f) => (f.group ?? "org") === id);
|
|
110
|
+
if (!mine.length) continue;
|
|
111
|
+
const group = el("div", { className: "navgroup" });
|
|
112
|
+
group.append(el("div", { className: "ghead", textContent: heading, title: note }));
|
|
113
|
+
for (const f of mine) {
|
|
114
|
+
const b = el("button", { className: "tfile tlayer", title: why(f) });
|
|
115
|
+
b.dataset.key = f.path;
|
|
116
|
+
const t = el("span", { className: "t" });
|
|
117
|
+
t.append(
|
|
118
|
+
el("span", { className: "lname", textContent: f.path.replace(/^(org|prompts)\//, "") }),
|
|
119
|
+
el("span", { className: "lrepo", textContent: why(f) }),
|
|
120
|
+
);
|
|
121
|
+
b.append(t);
|
|
122
|
+
b.onclick = () => open(f.path);
|
|
123
|
+
group.append(b);
|
|
124
|
+
}
|
|
125
|
+
tree.append(group);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
76
128
|
|
|
77
129
|
function open(path) {
|
|
130
|
+
if (!path) return;
|
|
78
131
|
S.orgOpen = path;
|
|
79
132
|
writeHash(false);
|
|
80
133
|
for (const o of tree.querySelectorAll(".tfile")) {
|
|
@@ -84,7 +137,7 @@ export function viewOrg(m) {
|
|
|
84
137
|
}
|
|
85
138
|
|
|
86
139
|
async function show(path) {
|
|
87
|
-
viewer.replaceChildren(
|
|
140
|
+
viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 7));
|
|
88
141
|
let text;
|
|
89
142
|
try {
|
|
90
143
|
text = await getFile(full(path));
|
|
@@ -97,55 +150,82 @@ export function viewOrg(m) {
|
|
|
97
150
|
|
|
98
151
|
function render(path, text) {
|
|
99
152
|
viewer.replaceChildren();
|
|
100
|
-
const
|
|
101
|
-
const head = el("div", { className: "row", style: "margin-bottom:4px" });
|
|
153
|
+
const head = el("div", { className: "row filehead" });
|
|
102
154
|
head.append(el("span", { className: "meta", textContent: full(path) + " · " + kb(text.length) }));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
155
|
+
/* The edit control used to be a word in a row of grey text, which is why "there is no easy
|
|
156
|
+
way to edit the org docs" was a fair thing to say about a screen that could edit them. */
|
|
157
|
+
const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
|
|
158
|
+
edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
|
|
159
|
+
head.append(edit);
|
|
160
|
+
const file = S.orgFiles.find((f) => f.path === path) ?? { path };
|
|
161
|
+
viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
|
|
162
|
+
|
|
163
|
+
if (path.endsWith(".yaml") || path.endsWith(".yml")) {
|
|
164
|
+
viewer.append(yamlPre(text));
|
|
108
165
|
return;
|
|
109
166
|
}
|
|
110
167
|
viewer.append(el("div", { className: "md doc", innerHTML: mdlite(text) }));
|
|
111
168
|
}
|
|
112
169
|
|
|
113
|
-
function
|
|
170
|
+
function editor(path, text) {
|
|
114
171
|
viewer.replaceChildren();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
);
|
|
172
|
+
const isYaml = /\.ya?ml$/.test(path);
|
|
173
|
+
|
|
174
|
+
const status = el("span", { className: "meta" });
|
|
175
|
+
const save = el("button", { className: "ghbtn primary", textContent: "Save and commit" });
|
|
176
|
+
const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
|
|
177
|
+
|
|
178
|
+
const bar = el("div", { className: "row filehead" });
|
|
179
|
+
bar.append(el("span", { className: "meta", textContent: full(path) }), save, cancel, status);
|
|
180
|
+
viewer.append(bar);
|
|
120
181
|
|
|
121
182
|
const ta = el("textarea", { value: text, className: "editor" });
|
|
122
183
|
viewer.append(ta);
|
|
123
184
|
grow(ta);
|
|
124
185
|
ta.addEventListener("input", () => grow(ta));
|
|
186
|
+
// ⌘S is what a person's hands do in a text box. Without it, saving means finding a button
|
|
187
|
+
// above a screenful of textarea you have just scrolled past.
|
|
188
|
+
ta.addEventListener("keydown", (e) => {
|
|
189
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
commit();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
ta.focus?.();
|
|
125
195
|
|
|
126
|
-
const status = el("span", { className: "meta" });
|
|
127
|
-
const save = el("button", { className: "ghbtn primary", textContent: "Save and commit" });
|
|
128
|
-
const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
|
|
129
196
|
cancel.onclick = () => render(path, text);
|
|
130
|
-
|
|
197
|
+
save.onclick = commit;
|
|
198
|
+
|
|
131
199
|
viewer.append(
|
|
132
200
|
el("p", {
|
|
133
201
|
className: "editnote",
|
|
134
202
|
textContent:
|
|
135
|
-
"Commits to " + S.data.opsName + " and pushes
|
|
136
|
-
"next run." +
|
|
137
|
-
(
|
|
203
|
+
"⌘S saves. Commits to " + S.data.opsName + " and pushes, and every staff member picks " +
|
|
204
|
+
"it up on their next run." +
|
|
205
|
+
(isYaml
|
|
138
206
|
? " org.yaml is checked before it is written: bad YAML, or a missing org or name, is refused rather than committed."
|
|
139
207
|
: ""),
|
|
140
208
|
}),
|
|
141
209
|
);
|
|
142
210
|
|
|
143
|
-
|
|
211
|
+
async function commit() {
|
|
144
212
|
if (ta.value === text) {
|
|
145
213
|
status.textContent = "nothing changed";
|
|
214
|
+
status.className = "meta";
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
/* Only the manifest asks. A prose edit is one commit to revert and the agents pick it up
|
|
218
|
+
on their next run; org.yaml is the file that can stop every prompt composing, and the
|
|
219
|
+
portal has just told you it validates it. Asking every time taught people to click
|
|
220
|
+
through the question, which is worse than not asking. */
|
|
221
|
+
if (
|
|
222
|
+
isYaml &&
|
|
223
|
+
!confirm(
|
|
224
|
+
"Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.",
|
|
225
|
+
)
|
|
226
|
+
) {
|
|
146
227
|
return;
|
|
147
228
|
}
|
|
148
|
-
if (!confirm("Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.")) return;
|
|
149
229
|
save.disabled = cancel.disabled = true;
|
|
150
230
|
status.textContent = "committing…";
|
|
151
231
|
status.className = "meta";
|
|
@@ -162,7 +242,7 @@ export function viewOrg(m) {
|
|
|
162
242
|
status.className = "meta err";
|
|
163
243
|
save.disabled = cancel.disabled = false;
|
|
164
244
|
}
|
|
165
|
-
}
|
|
245
|
+
}
|
|
166
246
|
}
|
|
167
247
|
}
|
|
168
248
|
|
|
@@ -14,6 +14,7 @@ import { askText } from "../dialog.js";
|
|
|
14
14
|
import { el, esc, grow, kb, toClipboard } from "../dom.js";
|
|
15
15
|
import { icon } from "../icons.js";
|
|
16
16
|
import { mdlite } from "../md.js";
|
|
17
|
+
import { render } from "../router.js";
|
|
17
18
|
import { S, staff, writeHash } from "../state.js";
|
|
18
19
|
import { diffStat, unifiedDiff } from "../textdiff.js";
|
|
19
20
|
import { renderDiff } from "./changed.js";
|
|
@@ -21,9 +22,53 @@ import { renderDiff } from "./changed.js";
|
|
|
21
22
|
const KINDS = [
|
|
22
23
|
["daily", "Daily", "the scheduled run"],
|
|
23
24
|
["mention", "Mention", "someone typed @them in an issue"],
|
|
24
|
-
["pr-mention", "PR mention", "someone typed @them on a pull request"],
|
|
25
25
|
];
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Build the paste-ready brief on the server, where compose.mjs lives, and copy it.
|
|
29
|
+
*
|
|
30
|
+
* A finding pre-fills the box rather than skipping it: what it wrote is a starting point, and
|
|
31
|
+
* "and keep it in operating.md" is exactly the sort of thing you want to add.
|
|
32
|
+
*
|
|
33
|
+
* Exported because the findings themselves live on Health now, and the fix for one of them is
|
|
34
|
+
* the same brief whichever screen you asked from.
|
|
35
|
+
*/
|
|
36
|
+
export async function copyAmendBrief(handle, kind, want, btn, note) {
|
|
37
|
+
const label = btn.textContent;
|
|
38
|
+
const asked = await askText({
|
|
39
|
+
title: "What do you want changed?",
|
|
40
|
+
hint:
|
|
41
|
+
"This goes at the top of a brief carrying the whole prompt and every file it is " +
|
|
42
|
+
"made of. Say it the way you would say it to a person.",
|
|
43
|
+
value: want,
|
|
44
|
+
placeholder: "stop opening decision issues for anything reversible",
|
|
45
|
+
confirm: "Copy the brief",
|
|
46
|
+
});
|
|
47
|
+
if (!asked) return;
|
|
48
|
+
btn.disabled = true;
|
|
49
|
+
btn.textContent = "building…";
|
|
50
|
+
try {
|
|
51
|
+
const url =
|
|
52
|
+
"/api/amend?staff=" + encodeURIComponent(handle) +
|
|
53
|
+
"&kind=" + encodeURIComponent(kind) +
|
|
54
|
+
"&want=" + encodeURIComponent(asked);
|
|
55
|
+
const text = await (await fetch(url, { cache: "no-store" })).text();
|
|
56
|
+
btn.disabled = false;
|
|
57
|
+
toClipboard(text, btn, label);
|
|
58
|
+
if (note) {
|
|
59
|
+
note.textContent = "paste it into whatever agent you use";
|
|
60
|
+
note.className = "meta";
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
btn.disabled = false;
|
|
64
|
+
btn.textContent = label;
|
|
65
|
+
if (note) {
|
|
66
|
+
note.textContent = e.message;
|
|
67
|
+
note.className = "meta err";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
27
72
|
export function viewPrompt(m) {
|
|
28
73
|
const s = staff();
|
|
29
74
|
if (!S.promptKind) S.promptKind = "daily";
|
|
@@ -54,40 +99,7 @@ export function viewPrompt(m) {
|
|
|
54
99
|
const note = el("span", { className: "meta" });
|
|
55
100
|
m.append(el("div", { className: "row", style: "margin-bottom:16px" }, [kind, help, copy, note]));
|
|
56
101
|
|
|
57
|
-
|
|
58
|
-
A finding pre-fills the box rather than skipping it: what it wrote is a starting point,
|
|
59
|
-
and "and keep it in operating.md" is exactly the sort of thing you want to add. */
|
|
60
|
-
async function copyAmend(want, btn) {
|
|
61
|
-
const target = btn ?? help;
|
|
62
|
-
const label = target.textContent;
|
|
63
|
-
const asked = await askText({
|
|
64
|
-
title: "What do you want changed?",
|
|
65
|
-
hint:
|
|
66
|
-
"This goes at the top of a brief carrying the whole prompt and every file it is " +
|
|
67
|
-
"made of. Say it the way you would say it to a person.",
|
|
68
|
-
value: want,
|
|
69
|
-
placeholder: "stop opening decision issues for anything reversible",
|
|
70
|
-
confirm: "Copy the brief",
|
|
71
|
-
});
|
|
72
|
-
if (!asked) return;
|
|
73
|
-
target.disabled = true;
|
|
74
|
-
target.textContent = "building…";
|
|
75
|
-
try {
|
|
76
|
-
const url =
|
|
77
|
-
"/api/amend?staff=" + encodeURIComponent(s.handle) +
|
|
78
|
-
"&kind=" + encodeURIComponent(S.promptKind) +
|
|
79
|
-
"&want=" + encodeURIComponent(asked);
|
|
80
|
-
const text = await (await fetch(url, { cache: "no-store" })).text();
|
|
81
|
-
target.disabled = false;
|
|
82
|
-
toClipboard(text, target, label);
|
|
83
|
-
note.textContent = "paste it into whatever agent you use";
|
|
84
|
-
} catch (e) {
|
|
85
|
-
target.disabled = false;
|
|
86
|
-
target.textContent = label;
|
|
87
|
-
note.textContent = e.message;
|
|
88
|
-
note.className = "meta err";
|
|
89
|
-
}
|
|
90
|
-
}
|
|
102
|
+
const copyAmend = (want, btn) => copyAmendBrief(s.handle, S.promptKind, want, btn ?? help, note);
|
|
91
103
|
|
|
92
104
|
const split = el("div", { className: "split" });
|
|
93
105
|
const tree = el("div", { className: "tree" });
|
|
@@ -140,12 +152,9 @@ export function viewPrompt(m) {
|
|
|
140
152
|
for (const l of view.layers) inlined.append(layerRow(l));
|
|
141
153
|
tree.append(inlined);
|
|
142
154
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
tree.append(bad);
|
|
147
|
-
}
|
|
148
|
-
|
|
155
|
+
/* No Problems section. This screen answers "what is sent"; whether what is sent is any
|
|
156
|
+
good is a health question, and it is asked and answered on Health. Having both here
|
|
157
|
+
made the tree beside the prompt half prompt and half complaints. */
|
|
149
158
|
const named = group("Named, not inlined");
|
|
150
159
|
named.append(
|
|
151
160
|
el("p", {
|
|
@@ -160,28 +169,6 @@ export function viewPrompt(m) {
|
|
|
160
169
|
open(S.promptOpen ?? "composed");
|
|
161
170
|
}
|
|
162
171
|
|
|
163
|
-
/* A finding is only half useful. The other half is the sentence that goes into the brief,
|
|
164
|
-
which is why every one of them carries a `want`. */
|
|
165
|
-
function problemRow(p) {
|
|
166
|
-
const b = el("div", { className: "prob " + p.level });
|
|
167
|
-
b.append(el("div", { className: "ptitle", textContent: p.title }));
|
|
168
|
-
b.append(el("div", { className: "pdetail", textContent: p.detail }));
|
|
169
|
-
const fix = el("button", { className: "ghbtn", textContent: "Copy a prompt to fix this" });
|
|
170
|
-
fix.onclick = () => copyAmend(p.want, fix);
|
|
171
|
-
const row = el("div", { className: "row", style: "margin-top:8px" }, [fix]);
|
|
172
|
-
if (p.path) {
|
|
173
|
-
row.append(
|
|
174
|
-
el("button", {
|
|
175
|
-
className: "ghbtn",
|
|
176
|
-
textContent: "Open the file",
|
|
177
|
-
onclick: () => open(p.path),
|
|
178
|
-
}),
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
b.append(row);
|
|
182
|
-
return b;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
172
|
function group(title, right) {
|
|
186
173
|
const g = el("div", { className: "navgroup" });
|
|
187
174
|
const h = el("div", { className: "ghead", textContent: title });
|
|
@@ -19,6 +19,11 @@ export async function viewSetup(main) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function render(main) {
|
|
22
|
+
/* The sidebar is written by boot() before any of this is known. Once a tenant has been
|
|
23
|
+
adopted, leaving it on "no org yet" contradicts the card directly below it. */
|
|
24
|
+
const brand = $("#orgname");
|
|
25
|
+
if (brand) brand.textContent = S.tenant.found ? (S.tenant.org ?? "set up") : "no org yet";
|
|
26
|
+
|
|
22
27
|
main.replaceChildren();
|
|
23
28
|
main.append(el("h1", { textContent: "Set up your org" }));
|
|
24
29
|
main.append(
|
|
@@ -156,10 +161,11 @@ function stepOrg(main) {
|
|
|
156
161
|
const human = field("human", "Your GitHub login", S.gh.login ?? "", "The person the agents answer to.");
|
|
157
162
|
|
|
158
163
|
card.append(form);
|
|
164
|
+
const agentHead = el("h3", { textContent: "Which coding agent runs a session" });
|
|
159
165
|
|
|
160
166
|
/* The agent. Its id decides the secret name later, so setup can name it exactly rather than
|
|
161
167
|
saying "it depends on the runner". */
|
|
162
|
-
card.append(
|
|
168
|
+
card.append(agentHead);
|
|
163
169
|
const agents = el("div", { className: "choices" });
|
|
164
170
|
S.agents.forEach((a, i) => {
|
|
165
171
|
const opt = el("label", { className: "choice" });
|
|
@@ -170,14 +176,13 @@ function stepOrg(main) {
|
|
|
170
176
|
agents.append(opt);
|
|
171
177
|
});
|
|
172
178
|
card.append(agents);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
);
|
|
179
|
+
const agentNote = el("p", {
|
|
180
|
+
className: "sub",
|
|
181
|
+
textContent:
|
|
182
|
+
"Anything else works too: a runner is an install command, a run command and the name of " +
|
|
183
|
+
"the secret holding its credential. Write those three into org.yaml afterwards.",
|
|
184
|
+
});
|
|
185
|
+
card.append(agentNote);
|
|
181
186
|
|
|
182
187
|
const params = () => ({
|
|
183
188
|
org: org.value === "__other" ? other.input.value.trim() : org.value,
|
|
@@ -200,11 +205,29 @@ function stepOrg(main) {
|
|
|
200
205
|
join.hidden = !joining;
|
|
201
206
|
plan.hidden = joining;
|
|
202
207
|
if (joining) go.hidden = true;
|
|
208
|
+
|
|
209
|
+
/* An org that already runs roster has answered all of these: its own org.yaml names the
|
|
210
|
+
business, the human and the agent. Leaving them on screen asks for values that will be
|
|
211
|
+
thrown away, and implies the checkout is going to use them. */
|
|
212
|
+
for (const bit of [name.wrap, human.wrap, agentHead, agents, agentNote]) bit.hidden = joining;
|
|
203
213
|
}
|
|
204
214
|
|
|
205
215
|
join.onclick = async () => {
|
|
206
216
|
join.disabled = true;
|
|
207
|
-
|
|
217
|
+
/* Cloning an ops repo and every brain took 38 seconds against a real org, behind a word
|
|
218
|
+
that never changed. A static "Cloning…" for that long reads as hung, so this says what
|
|
219
|
+
it is doing and keeps a clock running to show it is alive. */
|
|
220
|
+
const clock = el("p", { className: "sub" });
|
|
221
|
+
out.replaceChildren(clock);
|
|
222
|
+
const started = Date.now();
|
|
223
|
+
const tick = () => {
|
|
224
|
+
const s = Math.round((Date.now() - started) / 1000);
|
|
225
|
+
clock.textContent =
|
|
226
|
+
`Cloning ${existing}: the ops repo and one for each staff member. ${s}s. ` +
|
|
227
|
+
"A first checkout usually takes under a minute.";
|
|
228
|
+
};
|
|
229
|
+
tick();
|
|
230
|
+
const ticking = setInterval(tick, 1000);
|
|
208
231
|
try {
|
|
209
232
|
const result = await joinOrg(existing);
|
|
210
233
|
if (!result.ok) throw new Error(result.error);
|
|
@@ -219,6 +242,7 @@ function stepOrg(main) {
|
|
|
219
242
|
} catch (err) {
|
|
220
243
|
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
221
244
|
} finally {
|
|
245
|
+
clearInterval(ticking);
|
|
222
246
|
join.disabled = false;
|
|
223
247
|
}
|
|
224
248
|
};
|
|
@@ -279,12 +303,12 @@ function stepOrg(main) {
|
|
|
279
303
|
/* ------------------------- 5 · the two GitHub insists on ------------------------- */
|
|
280
304
|
|
|
281
305
|
function afterCreate() {
|
|
282
|
-
const card = step(3, "
|
|
306
|
+
const card = step(3, "What is left", false);
|
|
283
307
|
card.append(
|
|
284
308
|
el("p", {
|
|
285
309
|
textContent:
|
|
286
|
-
"
|
|
287
|
-
"you skip it.",
|
|
310
|
+
"None of these can be done for you, and the first fails in a way that wastes an " +
|
|
311
|
+
"afternoon if you skip it.",
|
|
288
312
|
}),
|
|
289
313
|
);
|
|
290
314
|
|
|
@@ -15,6 +15,7 @@ import { icon } from "../icons.js";
|
|
|
15
15
|
import { refreshAll } from "../refresh.js";
|
|
16
16
|
import { S } from "../state.js";
|
|
17
17
|
import { appPanel } from "./app.js";
|
|
18
|
+
import { cronText } from "./health.js";
|
|
18
19
|
import { paste } from "./paste.js";
|
|
19
20
|
|
|
20
21
|
export function viewStaff(m) {
|
|
@@ -110,7 +111,7 @@ export function viewStaff(m) {
|
|
|
110
111
|
const handle = field("handle", "cfo", "lowercase, digits and dashes");
|
|
111
112
|
const name = field("name", "Chief Financial Officer", "defaults to the handle, uppercased");
|
|
112
113
|
const dir = field("dir", "finance", "directory and repo name; defaults to the handle");
|
|
113
|
-
const schedule =
|
|
114
|
+
const schedule = scheduleField();
|
|
114
115
|
for (const f of [handle, name, dir, schedule]) box.append(f.row);
|
|
115
116
|
|
|
116
117
|
const status = el("span", { className: "meta" });
|
|
@@ -134,7 +135,8 @@ export function viewStaff(m) {
|
|
|
134
135
|
out.replaceChildren();
|
|
135
136
|
const params = new URLSearchParams({ handle: handle.input.value.trim() });
|
|
136
137
|
for (const [key, f] of [["name", name], ["dir", dir], ["schedule", schedule]]) {
|
|
137
|
-
|
|
138
|
+
const value = (f.value ? f.value() : f.input.value).trim();
|
|
139
|
+
if (value) params.set(key, value);
|
|
138
140
|
}
|
|
139
141
|
try {
|
|
140
142
|
const data = await (await fetch("/api/staff/plan?" + params, { cache: "no-store" })).json();
|
|
@@ -281,6 +283,64 @@ function field(label, placeholder, hint) {
|
|
|
281
283
|
return { row, input };
|
|
282
284
|
}
|
|
283
285
|
|
|
286
|
+
/**
|
|
287
|
+
* When the daily run happens, as a time and a set of days rather than as cron.
|
|
288
|
+
*
|
|
289
|
+
* The value on the wire is still a cron expression, because that is what goes in the workflow
|
|
290
|
+
* and what `roster hire` takes. But nobody hiring their first staff member knows that
|
|
291
|
+
* `0 9 * * 1-5` is nine in the morning on weekdays, and a field that demands it is a field
|
|
292
|
+
* that gets a wrong answer or an empty one.
|
|
293
|
+
*
|
|
294
|
+
* UTC, said out loud, with the local equivalent beside it: GitHub schedules in UTC, and an
|
|
295
|
+
* agent that starts an hour off twice a year is worse than one you had to think about once.
|
|
296
|
+
*/
|
|
297
|
+
function scheduleField() {
|
|
298
|
+
const time = el("input", { type: "time", value: "", step: "60", style: "width:130px" });
|
|
299
|
+
const days = el("select");
|
|
300
|
+
days.append(
|
|
301
|
+
el("option", { value: "1-5", textContent: "Weekdays" }),
|
|
302
|
+
el("option", { value: "*", textContent: "Every day" }),
|
|
303
|
+
el("option", { value: "1", textContent: "Mondays" }),
|
|
304
|
+
el("option", { value: "1,3,5", textContent: "Mon, Wed, Fri" }),
|
|
305
|
+
);
|
|
306
|
+
// Said rather than relying on "the first option is selected", which is true of a rendered
|
|
307
|
+
// <select> and not of one that has only been built.
|
|
308
|
+
days.value = "1-5";
|
|
309
|
+
|
|
310
|
+
const said = el("small");
|
|
311
|
+
const value = () => {
|
|
312
|
+
if (!time.value) return "";
|
|
313
|
+
const [h, m] = time.value.split(":");
|
|
314
|
+
return `${Number(m)} ${Number(h)} * * ${days.value}`;
|
|
315
|
+
};
|
|
316
|
+
const say = () => {
|
|
317
|
+
const cron = value();
|
|
318
|
+
said.textContent = cron
|
|
319
|
+
? cronText(cron) + local(time.value)
|
|
320
|
+
: "Leave it empty for a slot clear of everyone else.";
|
|
321
|
+
};
|
|
322
|
+
time.oninput = say;
|
|
323
|
+
days.onchange = say;
|
|
324
|
+
say();
|
|
325
|
+
|
|
326
|
+
const row = el("div", { className: "ffield" });
|
|
327
|
+
row.append(
|
|
328
|
+
el("label", { textContent: "schedule" }),
|
|
329
|
+
el("div", { className: "row" }, [time, days, el("span", { className: "meta", textContent: "UTC" })]),
|
|
330
|
+
said,
|
|
331
|
+
);
|
|
332
|
+
return { row, input: time, value };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** What that UTC time is where the person reading it is, when the two differ. */
|
|
336
|
+
function local(hhmm) {
|
|
337
|
+
if (!hhmm) return "";
|
|
338
|
+
const [h, m] = hhmm.split(":").map(Number);
|
|
339
|
+
const at = new Date(Date.UTC(2026, 0, 5, h, m));
|
|
340
|
+
const here = at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
341
|
+
return here === hhmm ? "" : " · " + here + " where you are";
|
|
342
|
+
}
|
|
343
|
+
|
|
284
344
|
const MARK = { ok: "check", stop: "closed", keep: "issue-open", todo: "dash", warn: "label" };
|
|
285
345
|
|
|
286
346
|
function line(kind, text) {
|