@nanocollective/roster 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +65 -84
- package/dist/cli.js +4687 -2495
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- package/docs/architecture.md +13 -5
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/support.md +60 -0
- package/docs/commands.md +90 -11
- package/docs/concepts.md +64 -12
- package/docs/cost.md +39 -3
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +21 -6
- package/docs/export.md +2 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +118 -80
- 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 +94 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +74 -11
- package/docs/portal.md +261 -47
- package/docs/prompts.md +71 -11
- package/docs/security.md +43 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +16 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +6 -0
- package/docs/writing-a-charter.md +33 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/staff.yaml +0 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/ops/.github/workflows/session.yaml +117 -40
- package/templates/ops/agents.mjs +127 -8
- package/templates/ops/compose.mjs +77 -7
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -7
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +18 -2
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/base.css +238 -64
- package/templates/portal/css/brain.css +30 -20
- package/templates/portal/css/diff.css +15 -10
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +31 -10
- package/templates/portal/css/inbox.css +117 -14
- package/templates/portal/css/layout.css +92 -41
- package/templates/portal/css/markdown.css +38 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +50 -37
- package/templates/portal/index.html +24 -3
- package/templates/portal/js/api.js +65 -4
- package/templates/portal/js/app.js +112 -12
- package/templates/portal/js/dialog.js +94 -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 +55 -8
- package/templates/portal/js/views/app.js +23 -5
- package/templates/portal/js/views/checklist.js +20 -7
- package/templates/portal/js/views/credential.js +93 -0
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +178 -37
- package/templates/portal/js/views/inbox.js +938 -98
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +161 -61
- package/templates/portal/js/views/paste.js +29 -0
- package/templates/portal/js/views/prompt.js +57 -66
- package/templates/portal/js/views/repos.js +12 -7
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +302 -56
- package/templates/portal/js/views/staff.js +143 -22
- 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
|
@@ -7,10 +7,75 @@
|
|
|
7
7
|
* Its own module because both the shell and the inbox need it, and importing the shell from
|
|
8
8
|
* a view would make the module graph a ring. */
|
|
9
9
|
|
|
10
|
-
import { getOrg, getSync } from "./api.js";
|
|
10
|
+
import { getInbox, getOrg, getSync } from "./api.js";
|
|
11
11
|
import { $, ago, el, esc } from "./dom.js";
|
|
12
12
|
import { render } from "./router.js";
|
|
13
|
-
import { S } from "./state.js";
|
|
13
|
+
import { openCount, openPrCount, S } from "./state.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The inbox, fetched at most once at a time.
|
|
17
|
+
*
|
|
18
|
+
* Two things want it the moment the page opens: the sidebar, which counts what is waiting on
|
|
19
|
+
* you, and the Inbox screen, if that is where the URL landed. Both used to ask separately, and
|
|
20
|
+
* the second one paid a second round trip through every repo on GitHub to learn what the first
|
|
21
|
+
* was already finding out.
|
|
22
|
+
*
|
|
23
|
+
* So the request is shared. `force` always goes to GitHub — it is the refresh button — and
|
|
24
|
+
* everything else takes whatever is in flight or already loaded.
|
|
25
|
+
*/
|
|
26
|
+
let inflight = null;
|
|
27
|
+
|
|
28
|
+
export function ensureInbox(force) {
|
|
29
|
+
if (!force && S.inbox) return Promise.resolve(S.inbox);
|
|
30
|
+
if (!force && inflight) return inflight;
|
|
31
|
+
const asked = getInbox(force)
|
|
32
|
+
.then((data) => {
|
|
33
|
+
S.inbox = data;
|
|
34
|
+
if (inflight === asked) inflight = null;
|
|
35
|
+
return data;
|
|
36
|
+
})
|
|
37
|
+
.catch((err) => {
|
|
38
|
+
if (inflight === asked) inflight = null;
|
|
39
|
+
throw err;
|
|
40
|
+
});
|
|
41
|
+
inflight = asked;
|
|
42
|
+
return asked;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The two sidebar counts, wherever they are asked for.
|
|
47
|
+
*
|
|
48
|
+
* They live here rather than on the Inbox screen because they are the shell's, and because the
|
|
49
|
+
* whole point of this pass is that they are right before anybody has opened that screen. While
|
|
50
|
+
* the first fetch is out they show a placeholder rather than nothing: an empty badge reads as
|
|
51
|
+
* "zero", and zero is a different claim from "still counting".
|
|
52
|
+
*/
|
|
53
|
+
export function stampCounts() {
|
|
54
|
+
const waiting = !S.inbox;
|
|
55
|
+
for (const [sel, n] of [
|
|
56
|
+
["#inboxcount", openCount],
|
|
57
|
+
["#prcount", openPrCount],
|
|
58
|
+
]) {
|
|
59
|
+
const slot = document.querySelector(sel);
|
|
60
|
+
if (!slot) continue;
|
|
61
|
+
slot.textContent = waiting ? "" : String(n());
|
|
62
|
+
slot.classList.toggle("wait", waiting);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Count what is waiting on you without opening the screen that lists it.
|
|
68
|
+
*
|
|
69
|
+
* The badges used to fill in only once something else caused a render with an inbox loaded,
|
|
70
|
+
* which in practice meant "after you visit the Inbox". A sidebar that says nothing until you
|
|
71
|
+
* look at it is not a sidebar.
|
|
72
|
+
*/
|
|
73
|
+
export function countInBackground() {
|
|
74
|
+
stampCounts();
|
|
75
|
+
return ensureInbox(false)
|
|
76
|
+
.then(stampCounts)
|
|
77
|
+
.catch(() => stampCounts());
|
|
78
|
+
}
|
|
14
79
|
|
|
15
80
|
export async function refreshAll(soft) {
|
|
16
81
|
const bar = $("#refreshall");
|
|
@@ -21,14 +86,54 @@ export async function refreshAll(soft) {
|
|
|
21
86
|
S.sync = await getSync();
|
|
22
87
|
S.data = await getOrg();
|
|
23
88
|
S.inbox = null; // the inbox re-fetches itself on next paint
|
|
89
|
+
inflight = null;
|
|
24
90
|
S.loadedAt = new Date();
|
|
25
91
|
if (!soft) render();
|
|
26
92
|
stampLoaded();
|
|
93
|
+
// The badges are now claiming a count from before the sync. Re-ask, and say so meanwhile.
|
|
94
|
+
countInBackground();
|
|
95
|
+
} finally {
|
|
96
|
+
bar?.classList.remove("spin");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The same thing, for coming back to the tab.
|
|
102
|
+
*
|
|
103
|
+
* Asked for by a focus event rather than by a person, so it has to earn every visible effect.
|
|
104
|
+
* A full refresh drops the inbox, which sends the next paint to GitHub for four seconds and
|
|
105
|
+
* throws away whatever was on screen — including a half-written issue. Almost every focus
|
|
106
|
+
* finds nothing new, so this one syncs, looks, and only then does any of that.
|
|
107
|
+
*/
|
|
108
|
+
export async function refreshQuietly() {
|
|
109
|
+
const bar = $("#refreshall");
|
|
110
|
+
bar?.classList.add("spin");
|
|
111
|
+
try {
|
|
112
|
+
S.sync = await getSync();
|
|
113
|
+
const data = await getOrg();
|
|
114
|
+
const changed = shape(data) !== shape(S.data);
|
|
115
|
+
S.data = data;
|
|
116
|
+
S.loadedAt = new Date();
|
|
117
|
+
stampLoaded();
|
|
118
|
+
// Something landed in a checkout: the inbox is stale too, and the screen should say so.
|
|
119
|
+
if (changed || (S.sync?.results ?? []).some((r) => r.pulled)) {
|
|
120
|
+
S.inbox = null;
|
|
121
|
+
inflight = null;
|
|
122
|
+
render();
|
|
123
|
+
countInBackground();
|
|
124
|
+
}
|
|
27
125
|
} finally {
|
|
28
126
|
bar?.classList.remove("spin");
|
|
29
127
|
}
|
|
30
128
|
}
|
|
31
129
|
|
|
130
|
+
/** The export minus its timestamp, which is stamped per request and always differs. */
|
|
131
|
+
function shape(data) {
|
|
132
|
+
if (!data) return "";
|
|
133
|
+
const { generatedAt, ...rest } = data;
|
|
134
|
+
return JSON.stringify(rest);
|
|
135
|
+
}
|
|
136
|
+
|
|
32
137
|
export function stampLoaded() {
|
|
33
138
|
const s = $("#loaded");
|
|
34
139
|
if (s && S.loadedAt) s.textContent = "data " + ago(S.loadedAt.toISOString());
|
|
@@ -40,20 +145,45 @@ export function syncNotice() {
|
|
|
40
145
|
const stuck = (S.sync?.results ?? []).filter((r) => r.skipped || r.error || r.behind > 0);
|
|
41
146
|
if (!stuck.length) return null;
|
|
42
147
|
const d = el("div", { className: "notice" });
|
|
43
|
-
|
|
148
|
+
const lines = el("div");
|
|
149
|
+
lines.innerHTML = stuck
|
|
44
150
|
.map(
|
|
45
151
|
(r) =>
|
|
46
152
|
"<b>" + esc(r.dir) + "</b> " +
|
|
47
153
|
(r.error
|
|
48
154
|
? "could not sync: " + esc(r.error)
|
|
49
155
|
: r.skipped === "dirty"
|
|
50
|
-
? "is " + r.behind + " behind and has uncommitted changes, so it was not pulled"
|
|
156
|
+
? "is " + r.behind + " behind and has uncommitted changes, so it was not pulled. " +
|
|
157
|
+
"Commit or stash them, then pull."
|
|
51
158
|
: r.skipped === "diverged"
|
|
52
|
-
? "has diverged (" + r.ahead + " ahead, " + r.behind + " behind)"
|
|
159
|
+
? "has diverged (" + r.ahead + " ahead, " + r.behind + " behind). " +
|
|
160
|
+
"Push or rebase it by hand: a merge is not this button's to make."
|
|
53
161
|
: r.skipped === "no-remote"
|
|
54
162
|
? "has no remote"
|
|
55
|
-
: "is
|
|
163
|
+
: "is " + r.behind + " behind"),
|
|
56
164
|
)
|
|
57
165
|
.join("<br>");
|
|
166
|
+
d.append(lines);
|
|
167
|
+
|
|
168
|
+
/* The same conservative pull `roster portal` runs on a refresh, on demand: fetch, and pull
|
|
169
|
+
only what is clean and fast-forward. It is here because the answer to "commit those and
|
|
170
|
+
pull" should not be to reload the page and hope. */
|
|
171
|
+
const status = el("span", { className: "meta" });
|
|
172
|
+
const pull = el("button", { className: "ghbtn", textContent: "Pull now" });
|
|
173
|
+
pull.onclick = async () => {
|
|
174
|
+
pull.disabled = true;
|
|
175
|
+
status.textContent = "pulling…";
|
|
176
|
+
status.className = "meta";
|
|
177
|
+
S.sync = await getSync();
|
|
178
|
+
const pulled = (S.sync?.results ?? []).filter((r) => r.pulled);
|
|
179
|
+
// Anything that landed is on disk now, so the export and the inbox are both behind it.
|
|
180
|
+
if (pulled.length) await refreshAll(false);
|
|
181
|
+
else {
|
|
182
|
+
pull.disabled = false;
|
|
183
|
+
status.textContent = "nothing could be pulled";
|
|
184
|
+
render();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
d.append(el("div", { className: "row", style: "margin-top:10px" }, [pull, status]));
|
|
58
188
|
return d;
|
|
59
189
|
}
|
|
@@ -22,18 +22,25 @@ export const VIEWS = [
|
|
|
22
22
|
export const VIEW_ALIAS = { memory: "brain" };
|
|
23
23
|
|
|
24
24
|
/** Screens that belong to the org rather than to one staff member. */
|
|
25
|
-
export const ORG_WIDE = new Set(["inbox", "org", "staff", "docs"]);
|
|
25
|
+
export const ORG_WIDE = new Set(["setup", "inbox", "prs", "runs", "org", "staff", "docs"]);
|
|
26
|
+
|
|
27
|
+
/** The two screens that read the inbox, and so share its filters and its `?t=` thread. */
|
|
28
|
+
const INBOXY = new Set(["inbox", "prs"]);
|
|
26
29
|
|
|
27
30
|
export const S = {
|
|
28
31
|
/** The org export from /api/org. Everything the brain screens read comes off this. */
|
|
29
32
|
data: null,
|
|
30
33
|
docs: null,
|
|
31
34
|
inbox: null,
|
|
35
|
+
/** /api/runs. Kept once read: it is minutes of downloads on a cold cache. */
|
|
36
|
+
runs: null,
|
|
32
37
|
sync: null,
|
|
33
38
|
loadedAt: null,
|
|
34
39
|
|
|
35
40
|
staffHandle: null,
|
|
36
|
-
|
|
41
|
+
/* What is waiting on you, not whose brain you read last. The org-wide screens are where a
|
|
42
|
+
session starts, which is also why the Staff list below them opens folded. */
|
|
43
|
+
view: "inbox",
|
|
37
44
|
/** The inbox filter box. Brain and "what changed" keep their own, below. */
|
|
38
45
|
query: "",
|
|
39
46
|
|
|
@@ -48,13 +55,16 @@ export const S = {
|
|
|
48
55
|
changedFilter: "",
|
|
49
56
|
|
|
50
57
|
openDoc: null,
|
|
58
|
+
/** The docs search box. Its own field: the Docs screen keeps it while you read a page. */
|
|
59
|
+
docQuery: "",
|
|
51
60
|
|
|
52
61
|
/** Which kind of run the Prompt screen is showing, and which layer of it is open. */
|
|
53
62
|
promptKind: "daily",
|
|
54
63
|
promptOpen: null,
|
|
55
64
|
|
|
56
|
-
/** Which org-layer file the Org screen is showing. */
|
|
65
|
+
/** Which org-layer file the Org screen is showing, and what it found on disk. */
|
|
57
66
|
orgOpen: null,
|
|
67
|
+
orgFiles: [],
|
|
58
68
|
|
|
59
69
|
inboxFilter: "",
|
|
60
70
|
inboxStaff: "",
|
|
@@ -67,12 +77,40 @@ export const S = {
|
|
|
67
77
|
|
|
68
78
|
/* The sidebar badge counts open items, not everything loaded. Once the inbox could show
|
|
69
79
|
closed work the two stopped being the same number, and a badge that says 135 when 34 things
|
|
70
|
-
need you is worse than no badge.
|
|
80
|
+
need you is worse than no badge. Pull requests are excluded for the same reason: they are
|
|
81
|
+
Pending work's, and counting them here would bill you twice for the same pile. */
|
|
71
82
|
export const openCount = () =>
|
|
72
|
-
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN").length;
|
|
83
|
+
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind !== "pr").length;
|
|
84
|
+
|
|
85
|
+
/** The same, for the screen that is only pull requests. */
|
|
86
|
+
export const openPrCount = () =>
|
|
87
|
+
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind === "pr").length;
|
|
73
88
|
|
|
74
89
|
export const staff = () => S.data.staff.find((s) => s.handle === S.staffHandle) ?? S.data.staff[0];
|
|
75
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Everyone the staff answer to.
|
|
93
|
+
*
|
|
94
|
+
* An org can have more than one. `humans` is the list the export sends now; `human` is what a
|
|
95
|
+
* portal whose server predates the list sends, and falling back to it is what keeps the page
|
|
96
|
+
* working across the upgrade rather than showing an org with nobody in it.
|
|
97
|
+
*/
|
|
98
|
+
export const humansOf = () =>
|
|
99
|
+
S.data?.humans?.length ? S.data.humans : S.data?.human?.github ? [S.data.human] : [];
|
|
100
|
+
|
|
101
|
+
/** Whether a login is one of them. Assignment to any of them is assignment to "us". */
|
|
102
|
+
export const isHuman = (login) =>
|
|
103
|
+
!!login &&
|
|
104
|
+
humansOf().some((h) => String(h.github ?? "").toLowerCase() === String(login).toLowerCase());
|
|
105
|
+
|
|
106
|
+
/** What to call them collectively, for a filter label or a count. */
|
|
107
|
+
export const humanLabel = () => {
|
|
108
|
+
const humans = humansOf();
|
|
109
|
+
if (!humans.length) return "you";
|
|
110
|
+
if (humans.length === 1) return humans[0].name ?? humans[0].github ?? "you";
|
|
111
|
+
return humans.map((h) => h.name ?? h.github).join(" or ");
|
|
112
|
+
};
|
|
113
|
+
|
|
76
114
|
export function readHash() {
|
|
77
115
|
const raw = (location.hash || "").replace(/^#\/?/, "");
|
|
78
116
|
if (!raw) return null;
|
|
@@ -97,7 +135,14 @@ export function writeHash(push) {
|
|
|
97
135
|
if (S.applyingHash) return;
|
|
98
136
|
if (!S.staffHandle && !ORG_WIDE.has(S.view)) return;
|
|
99
137
|
const params = new URLSearchParams();
|
|
100
|
-
const q =
|
|
138
|
+
const q =
|
|
139
|
+
S.view === "brain"
|
|
140
|
+
? S.fileQuery
|
|
141
|
+
: S.view === "changed"
|
|
142
|
+
? S.changedQuery
|
|
143
|
+
: S.view === "docs"
|
|
144
|
+
? S.docQuery
|
|
145
|
+
: S.query;
|
|
101
146
|
if (q) params.set("q", q);
|
|
102
147
|
if (S.view === "brain" && S.openFile) params.set("f", S.openFile);
|
|
103
148
|
if (S.view === "docs" && S.openDoc) params.set("p", S.openDoc);
|
|
@@ -106,7 +151,7 @@ export function writeHash(push) {
|
|
|
106
151
|
if (S.promptKind !== "daily") params.set("k", S.promptKind);
|
|
107
152
|
if (S.promptOpen) params.set("f", S.promptOpen);
|
|
108
153
|
}
|
|
109
|
-
if (S.view
|
|
154
|
+
if (INBOXY.has(S.view)) {
|
|
110
155
|
if (S.inboxFilter) params.set("s", S.inboxFilter);
|
|
111
156
|
if (S.inboxStaff) params.set("w", S.inboxStaff);
|
|
112
157
|
if (S.inboxState) params.set("x", S.inboxState);
|
|
@@ -134,6 +179,8 @@ export function applyHash() {
|
|
|
134
179
|
S.openFile = h.f;
|
|
135
180
|
} else if (S.view === "changed") {
|
|
136
181
|
S.changedQuery = h.q;
|
|
182
|
+
} else if (S.view === "docs") {
|
|
183
|
+
S.docQuery = h.q;
|
|
137
184
|
} else {
|
|
138
185
|
S.query = h.q;
|
|
139
186
|
}
|
|
@@ -146,7 +193,7 @@ export function applyHash() {
|
|
|
146
193
|
S.promptOpen = h.f;
|
|
147
194
|
}
|
|
148
195
|
|
|
149
|
-
if (S.view
|
|
196
|
+
if (INBOXY.has(S.view)) {
|
|
150
197
|
S.inboxFilter = h.s;
|
|
151
198
|
S.inboxStaff = h.w;
|
|
152
199
|
S.inboxState = h.x;
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* it is one browser, one origin, and nothing extra to explain.
|
|
7
7
|
*
|
|
8
8
|
* What it still cannot do is install the App. Installing is a grant of access to specific
|
|
9
|
-
* repositories and GitHub asks a human to
|
|
10
|
-
* be worked around. */
|
|
9
|
+
* repositories and GitHub asks a human to confirm them, which is correct behaviour and should not
|
|
10
|
+
* be worked around. The link it hands back has the right repos already selected. */
|
|
11
11
|
|
|
12
12
|
import { appResult, startApp } from "../api.js";
|
|
13
13
|
import { el } from "../dom.js";
|
|
@@ -48,6 +48,11 @@ export function appPanel(opts) {
|
|
|
48
48
|
it: an App that exists is not a problem, it is a step you have already done. */
|
|
49
49
|
const note = el("div");
|
|
50
50
|
note.append(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
51
|
+
if (err.data?.install) {
|
|
52
|
+
note.append(
|
|
53
|
+
el("a", { className: "btn", href: err.data.install, target: "_blank", textContent: "Install it" }),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
51
56
|
out.replaceChildren(note);
|
|
52
57
|
return;
|
|
53
58
|
}
|
|
@@ -102,9 +107,21 @@ export function appPanel(opts) {
|
|
|
102
107
|
el("p", {
|
|
103
108
|
className: "sub",
|
|
104
109
|
textContent:
|
|
105
|
-
"
|
|
106
|
-
"
|
|
110
|
+
"The link opens GitHub's install page with the org and the repos already selected: " +
|
|
111
|
+
"this staff member's brain, every peer tracker it writes to, and the product repos. " +
|
|
112
|
+
"Check the list and confirm.",
|
|
107
113
|
}),
|
|
114
|
+
...((result.preselected ?? []).length
|
|
115
|
+
? [el("p", { className: "sub", textContent: "Selected: " + result.preselected.join(", ") })]
|
|
116
|
+
: []),
|
|
117
|
+
...((result.missing ?? []).length
|
|
118
|
+
? [
|
|
119
|
+
el("p", {
|
|
120
|
+
className: "err",
|
|
121
|
+
textContent: "Tick these yourself, their ids could not be read: " + result.missing.join(", "),
|
|
122
|
+
}),
|
|
123
|
+
]
|
|
124
|
+
: []),
|
|
108
125
|
el("a", {
|
|
109
126
|
className: "btn primary",
|
|
110
127
|
href: result.install,
|
|
@@ -115,7 +132,8 @@ export function appPanel(opts) {
|
|
|
115
132
|
className: "sub",
|
|
116
133
|
textContent:
|
|
117
134
|
"Do not verify this by reading the API. It reports what an App declares separately " +
|
|
118
|
-
"from what an installation was granted. Only a run that finished proves the chain
|
|
135
|
+
"from what an installation was granted. Only a run that finished proves the chain, " +
|
|
136
|
+
"which is what Run once now on the staff card is for.",
|
|
119
137
|
}),
|
|
120
138
|
);
|
|
121
139
|
out.replaceChildren(done);
|
|
@@ -9,8 +9,17 @@ import { el, toClipboard } from "../dom.js";
|
|
|
9
9
|
|
|
10
10
|
const LEVEL_ORDER = { fail: 0, warn: 1, ok: 2 };
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* @param opts.shell when Health draws this, the section it lives in: `{actions, say}`. The
|
|
14
|
+
* setup screen passes nothing and gets the layout it has always had. Its own screen is a
|
|
15
|
+
* wizard, where a chunky button and a paragraph under it are right; on Health it is one
|
|
16
|
+
* section of four, and has to look like the other three.
|
|
17
|
+
*/
|
|
12
18
|
export async function checklist(host, opts = {}) {
|
|
13
|
-
|
|
19
|
+
const inSection = Boolean(opts.shell);
|
|
20
|
+
const btnClass = inSection ? "ghbtn" : "btn";
|
|
21
|
+
const noteClass = inSection ? "hnote" : "sub";
|
|
22
|
+
host.replaceChildren(el("p", { className: noteClass, textContent: "Checking…" }));
|
|
14
23
|
|
|
15
24
|
let report;
|
|
16
25
|
try {
|
|
@@ -26,9 +35,10 @@ export async function checklist(host, opts = {}) {
|
|
|
26
35
|
const bad = findings.filter((f) => f.level !== "ok");
|
|
27
36
|
|
|
28
37
|
host.replaceChildren();
|
|
38
|
+
opts.shell?.say(bad.length ? String(bad.length) : "clean");
|
|
29
39
|
host.append(
|
|
30
40
|
el("p", {
|
|
31
|
-
className:
|
|
41
|
+
className: noteClass,
|
|
32
42
|
textContent: bad.length
|
|
33
43
|
? `${bad.filter((f) => f.level === "fail").length} failing, ${bad.filter((f) => f.level === "warn").length} to look at.` +
|
|
34
44
|
(report.online ? "" : " Local checks only.")
|
|
@@ -51,7 +61,7 @@ export async function checklist(host, opts = {}) {
|
|
|
51
61
|
|
|
52
62
|
/* The isitagentready move: the findings as instructions, for the agent you already have
|
|
53
63
|
pointed at this workspace. What only a person can do is deliberately not in that text. */
|
|
54
|
-
const copy = el("button", { className: "
|
|
64
|
+
const copy = el("button", { className: btnClass + " primary", textContent: "Copy all instructions" });
|
|
55
65
|
const note = el("span", { className: "meta" });
|
|
56
66
|
copy.onclick = async () => {
|
|
57
67
|
copy.disabled = true;
|
|
@@ -70,14 +80,17 @@ export async function checklist(host, opts = {}) {
|
|
|
70
80
|
}
|
|
71
81
|
};
|
|
72
82
|
|
|
73
|
-
const again = el("button", { className:
|
|
83
|
+
const again = el("button", { className: btnClass, textContent: "Check again" });
|
|
74
84
|
again.onclick = () => checklist(host, opts);
|
|
75
85
|
|
|
76
|
-
|
|
86
|
+
const feet = opts.shell?.actions ?? host;
|
|
87
|
+
if (opts.shell) feet.replaceChildren();
|
|
88
|
+
feet.append(copy, again, note);
|
|
77
89
|
if (bad.length) {
|
|
78
|
-
|
|
90
|
+
feet.append(
|
|
79
91
|
el("p", {
|
|
80
|
-
className:
|
|
92
|
+
className: noteClass,
|
|
93
|
+
style: "flex-basis:100%",
|
|
81
94
|
textContent:
|
|
82
95
|
"Paste it into Cursor, Claude Code or anything else that can edit files here, then " +
|
|
83
96
|
"check again. The ids above should be gone.",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* The agent's credential, stored once for the org.
|
|
2
|
+
*
|
|
3
|
+
* The value goes from this box to the local server in a POST, and from there to gh on its
|
|
4
|
+
* standard input. It is not kept in the page after it is sent, and nothing echoes it back. */
|
|
5
|
+
|
|
6
|
+
import { getCredential, storeCredential } from "../api.js";
|
|
7
|
+
import { el } from "../dom.js";
|
|
8
|
+
|
|
9
|
+
export function credentialPanel() {
|
|
10
|
+
const box = el("div", { className: "manual" });
|
|
11
|
+
box.append(el("b", { textContent: "Your agent's credential, once" }));
|
|
12
|
+
const body = el("div");
|
|
13
|
+
box.append(body);
|
|
14
|
+
body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
|
|
15
|
+
|
|
16
|
+
getCredential()
|
|
17
|
+
.then((c) => draw(body, c))
|
|
18
|
+
.catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
|
|
19
|
+
return box;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function draw(body, c) {
|
|
23
|
+
body.replaceChildren();
|
|
24
|
+
if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
|
|
25
|
+
|
|
26
|
+
if (!c.brains.length) {
|
|
27
|
+
body.append(
|
|
28
|
+
el("p", {
|
|
29
|
+
className: "sub",
|
|
30
|
+
textContent:
|
|
31
|
+
"Nothing reads it until somebody is hired, so this box opens once there is a staff " +
|
|
32
|
+
"member. It is asked for once, not once per hire.",
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (c.orgSecret) {
|
|
39
|
+
body.append(
|
|
40
|
+
el("p", {
|
|
41
|
+
className: "sub",
|
|
42
|
+
textContent:
|
|
43
|
+
c.name + " is already an org secret, shared with " + c.orgSecret.visibility + " repos. " +
|
|
44
|
+
"Each hire adds its brain to it. Paste a new one only to replace it.",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
body.append(
|
|
50
|
+
el("p", {
|
|
51
|
+
className: "sub",
|
|
52
|
+
textContent:
|
|
53
|
+
(c.plan.mode === "org"
|
|
54
|
+
? "Stored as one org secret, " + c.name + ", shared with " + c.brains.join(", ") + ". "
|
|
55
|
+
: "Stored as " + c.name + " on each of " + c.brains.join(", ") + ", because " + c.plan.reason + ". ") +
|
|
56
|
+
"It goes to gh on standard input and is never written to disk.",
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const input = el("input", { type: "password", placeholder: c.name, autocomplete: "off" });
|
|
61
|
+
input.style.width = "100%";
|
|
62
|
+
const go = el("button", { className: "btn primary", textContent: "Store it" });
|
|
63
|
+
const out = el("div", { style: "margin-top:9px" });
|
|
64
|
+
body.append(input, el("div", { className: "row", style: "margin-top:9px" }, [go]), out);
|
|
65
|
+
|
|
66
|
+
go.onclick = async () => {
|
|
67
|
+
const value = input.value.trim();
|
|
68
|
+
if (!value) {
|
|
69
|
+
input.focus();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
go.disabled = true;
|
|
73
|
+
out.replaceChildren(el("p", { className: "sub", textContent: "Storing…" }));
|
|
74
|
+
try {
|
|
75
|
+
const r = await storeCredential(value, false);
|
|
76
|
+
input.value = "";
|
|
77
|
+
if (!r.ok) throw new Error(r.error);
|
|
78
|
+
out.replaceChildren(
|
|
79
|
+
...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
|
|
80
|
+
el("p", {
|
|
81
|
+
textContent:
|
|
82
|
+
r.mode === "org"
|
|
83
|
+
? "Stored once for the org, shared with " + r.repos.length + " brains."
|
|
84
|
+
: "Stored on " + r.repos.join(", ") + ".",
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
89
|
+
} finally {
|
|
90
|
+
go.disabled = false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/* The framework's own documentation, read where you already are. Same renderer as a brain
|
|
2
2
|
file, so a link between pages behaves like a link between files rather than a dead end. */
|
|
3
3
|
|
|
4
|
-
import { getDoc, getDocs } from "../api.js";
|
|
5
|
-
import { el, slug } from "../dom.js";
|
|
4
|
+
import { getDoc, getDocs, searchDocs } from "../api.js";
|
|
5
|
+
import { el, esc, skeleton, slug } from "../dom.js";
|
|
6
6
|
import { mdlite } from "../md.js";
|
|
7
7
|
import { S, writeHash } from "../state.js";
|
|
8
8
|
|
|
@@ -16,6 +16,18 @@ export function viewDocs(m) {
|
|
|
16
16
|
}),
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
+
/* Titles are not enough. Twenty-odd pages is too many to scan and few enough for the server
|
|
20
|
+
to read in full on every keystroke, and what you are looking for — "which page explains the
|
|
21
|
+
mention gate" — is a sentence in a paragraph rather than a word in a heading. So the box
|
|
22
|
+
searches the text, and the list becomes the results while there is a query in it. */
|
|
23
|
+
const find = el("input", {
|
|
24
|
+
type: "search",
|
|
25
|
+
placeholder: "Search the docs…",
|
|
26
|
+
value: S.docQuery ?? "",
|
|
27
|
+
style: "width:100%;margin-bottom:12px",
|
|
28
|
+
});
|
|
29
|
+
m.append(find);
|
|
30
|
+
|
|
19
31
|
const split = el("div", { className: "split" });
|
|
20
32
|
const list = el("div", { className: "tree" });
|
|
21
33
|
const viewer = el("div", { className: "viewer" });
|
|
@@ -24,14 +36,44 @@ export function viewDocs(m) {
|
|
|
24
36
|
|
|
25
37
|
if (S.docs) paint();
|
|
26
38
|
else {
|
|
27
|
-
list.
|
|
39
|
+
list.replaceChildren(...skeleton("row", 8));
|
|
28
40
|
getDocs()
|
|
29
41
|
.then((d) => { S.docs = d; paint(); })
|
|
30
42
|
.catch((e) => list.replaceChildren(el("p", { className: "empty err", textContent: e.message })));
|
|
31
43
|
}
|
|
32
44
|
|
|
45
|
+
/* Debounced, because every keystroke is a read of every page. 140ms is below the point
|
|
46
|
+
where the list feels like it is lagging behind the typing. */
|
|
47
|
+
let timer = null;
|
|
48
|
+
find.oninput = () => {
|
|
49
|
+
S.docQuery = find.value;
|
|
50
|
+
writeHash(false);
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
timer = setTimeout(run, 140);
|
|
53
|
+
};
|
|
54
|
+
if (S.docQuery) run();
|
|
55
|
+
|
|
56
|
+
async function run() {
|
|
57
|
+
const q = (S.docQuery ?? "").trim();
|
|
58
|
+
if (!q) { paint(); return; }
|
|
59
|
+
let hits;
|
|
60
|
+
try {
|
|
61
|
+
hits = (await searchDocs(q)).hits ?? [];
|
|
62
|
+
} catch (e) {
|
|
63
|
+
list.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// A slow answer to a query you have already moved on from must not land.
|
|
67
|
+
if ((S.docQuery ?? "").trim() !== q) return;
|
|
68
|
+
paintHits(hits, q);
|
|
69
|
+
}
|
|
70
|
+
|
|
33
71
|
function paint() {
|
|
34
72
|
list.replaceChildren();
|
|
73
|
+
if (!S.docs?.length) {
|
|
74
|
+
list.append(el("p", { className: "empty", textContent: "No docs here." }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
35
77
|
for (const d of S.docs) {
|
|
36
78
|
const b = el("button", { className: "tfile tmem", title: d.file });
|
|
37
79
|
b.dataset.key = d.file;
|
|
@@ -43,6 +85,54 @@ export function viewDocs(m) {
|
|
|
43
85
|
show(S.openDoc ?? S.docs[0].file);
|
|
44
86
|
}
|
|
45
87
|
|
|
88
|
+
/** The same list, narrowed, with the lines that matched under each row. */
|
|
89
|
+
function paintHits(hits, q) {
|
|
90
|
+
list.replaceChildren();
|
|
91
|
+
list.append(
|
|
92
|
+
el("div", {
|
|
93
|
+
className: "tdir first",
|
|
94
|
+
textContent: hits.length
|
|
95
|
+
? hits.length + (hits.length === 1 ? " page" : " pages") + " mention that"
|
|
96
|
+
: "nothing mentions that",
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
if (!hits.length) {
|
|
100
|
+
list.append(
|
|
101
|
+
el("p", {
|
|
102
|
+
className: "empty",
|
|
103
|
+
textContent: "Every word has to appear somewhere on the page. Try fewer.",
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const h of hits) {
|
|
109
|
+
const b = el("button", { className: "tfile thit", title: h.file });
|
|
110
|
+
b.dataset.key = h.file;
|
|
111
|
+
const t = el("span", { className: "t" });
|
|
112
|
+
t.append(el("span", { className: "hitname", textContent: h.title }));
|
|
113
|
+
for (const line of h.matches ?? []) {
|
|
114
|
+
t.append(el("span", { className: "hitline", innerHTML: mark(line.text, q) }));
|
|
115
|
+
}
|
|
116
|
+
b.append(t);
|
|
117
|
+
b.setAttribute("aria-current", String(S.openDoc === h.file));
|
|
118
|
+
b.onclick = () => open(h.file);
|
|
119
|
+
list.append(b);
|
|
120
|
+
}
|
|
121
|
+
// Opening the first result is what makes typing feel like searching rather than filtering.
|
|
122
|
+
if (!hits.some((h) => h.file === S.openDoc)) open(hits[0].file);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The words that were asked for, shown in the line they were found in. */
|
|
126
|
+
function mark(text, q) {
|
|
127
|
+
const terms = q.toLowerCase().split(/\s+/).filter(Boolean)
|
|
128
|
+
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
129
|
+
let html = esc(text);
|
|
130
|
+
if (terms.length) {
|
|
131
|
+
html = html.replace(new RegExp("(" + terms.join("|") + ")", "gi"), "<em>$1</em>");
|
|
132
|
+
}
|
|
133
|
+
return html;
|
|
134
|
+
}
|
|
135
|
+
|
|
46
136
|
function open(file) {
|
|
47
137
|
S.openDoc = file;
|
|
48
138
|
writeHash(false);
|
|
@@ -53,7 +143,7 @@ export function viewDocs(m) {
|
|
|
53
143
|
}
|
|
54
144
|
|
|
55
145
|
async function show(file) {
|
|
56
|
-
viewer.replaceChildren(
|
|
146
|
+
viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 8));
|
|
57
147
|
let text;
|
|
58
148
|
try {
|
|
59
149
|
text = await getDoc(file);
|