@nanocollective/roster 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/cli.js +5679 -0
- package/docs/README.md +99 -0
- package/docs/agents.md +163 -0
- package/docs/architecture.md +121 -0
- package/docs/commands.md +223 -0
- package/docs/concepts.md +112 -0
- package/docs/cost.md +61 -0
- package/docs/developing.md +147 -0
- package/docs/doctor-codes.md +74 -0
- package/docs/export.md +113 -0
- package/docs/extending.md +97 -0
- package/docs/getting-started.md +134 -0
- package/docs/hosting.md +72 -0
- package/docs/manual-steps.md +163 -0
- package/docs/memory.md +71 -0
- package/docs/org-yaml.md +143 -0
- package/docs/portal.md +342 -0
- package/docs/prompts.md +133 -0
- package/docs/security.md +122 -0
- package/docs/session-workflow.md +112 -0
- package/docs/staff-yaml.md +163 -0
- package/docs/troubleshooting.md +189 -0
- package/docs/upgrading.md +83 -0
- package/docs/writing-a-charter.md +83 -0
- package/package.json +60 -0
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +33 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +65 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +50 -0
- package/templates/brain/CHARTER.md +49 -0
- package/templates/brain/README.md +18 -0
- package/templates/brain/drafts/README.md +7 -0
- package/templates/brain/log/decisions.md +6 -0
- package/templates/brain/memory/INDEX.md +28 -0
- package/templates/brain/staff.yaml +44 -0
- package/templates/brain/strategy/README.md +7 -0
- package/templates/briefs/amend.md +60 -0
- package/templates/briefs/charter.md +47 -0
- package/templates/briefs/discover.md +61 -0
- package/templates/briefs/voice.md +53 -0
- package/templates/ops/.github/workflows/session.yaml +333 -0
- package/templates/ops/agents.mjs +143 -0
- package/templates/ops/compose.mjs +333 -0
- package/templates/ops/org/guardrails.md +14 -0
- package/templates/ops/org/operating.md +82 -0
- package/templates/ops/org/voice.md +40 -0
- package/templates/ops/prompts/_identity.md +14 -0
- package/templates/ops/prompts/_paths.md +15 -0
- package/templates/ops/prompts/daily.md +82 -0
- package/templates/ops/prompts/mention.md +53 -0
- package/templates/ops/prompts/pr-mention.md +57 -0
- package/templates/ops/runner-plan.mjs +65 -0
- package/templates/portal/css/base.css +104 -0
- package/templates/portal/css/brain.css +106 -0
- package/templates/portal/css/diff.css +28 -0
- package/templates/portal/css/graph.css +34 -0
- package/templates/portal/css/health.css +41 -0
- package/templates/portal/css/inbox.css +79 -0
- package/templates/portal/css/layout.css +98 -0
- package/templates/portal/css/markdown.css +54 -0
- package/templates/portal/css/setup.css +106 -0
- package/templates/portal/index.html +55 -0
- package/templates/portal/js/api.js +74 -0
- package/templates/portal/js/app.js +282 -0
- package/templates/portal/js/dialog.js +70 -0
- package/templates/portal/js/dom.js +106 -0
- package/templates/portal/js/icons.js +94 -0
- package/templates/portal/js/md.js +386 -0
- package/templates/portal/js/refresh.js +59 -0
- package/templates/portal/js/router.js +20 -0
- package/templates/portal/js/state.js +160 -0
- package/templates/portal/js/textdiff.js +96 -0
- package/templates/portal/js/views/app.js +128 -0
- package/templates/portal/js/views/brain.js +260 -0
- package/templates/portal/js/views/changed.js +157 -0
- package/templates/portal/js/views/checklist.js +87 -0
- package/templates/portal/js/views/docs.js +84 -0
- package/templates/portal/js/views/files.js +95 -0
- package/templates/portal/js/views/graph.js +436 -0
- package/templates/portal/js/views/health.js +158 -0
- package/templates/portal/js/views/inbox.js +549 -0
- package/templates/portal/js/views/memory.js +135 -0
- package/templates/portal/js/views/org.js +175 -0
- package/templates/portal/js/views/paste.js +142 -0
- package/templates/portal/js/views/prompt.js +412 -0
- package/templates/portal/js/views/repos.js +92 -0
- package/templates/portal/js/views/setup.js +344 -0
- package/templates/portal/js/views/staff.js +290 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* Every screen's state in one object, and the URL that mirrors it.
|
|
2
|
+
*
|
|
3
|
+
* The URL is the state, so a refresh lands where you were and a link is shareable. Changing
|
|
4
|
+
* staff or view pushes; typing in a filter replaces, so a search does not bury the back
|
|
5
|
+
* button under one entry per keystroke.
|
|
6
|
+
*
|
|
7
|
+
* This used to be a wall of top-level `var`s in a classic script, kept as `var` on purpose
|
|
8
|
+
* because a top-level `let` is not reachable as a global and the state was otherwise
|
|
9
|
+
* untestable. As a module it is exported, so it is neither global nor hidden. */
|
|
10
|
+
|
|
11
|
+
export const VIEWS = [
|
|
12
|
+
["brain", "Brain"],
|
|
13
|
+
["prompt", "Prompt"],
|
|
14
|
+
["graph", "Graph"],
|
|
15
|
+
["changed", "What changed"],
|
|
16
|
+
["health", "Health"],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/* Memory used to be its own view. It is a surface of the brain — both manifests already
|
|
20
|
+
declare `memory/` with `render: memory` — so it is now the brain's first stop, and the
|
|
21
|
+
old links still land somewhere sensible. */
|
|
22
|
+
export const VIEW_ALIAS = { memory: "brain" };
|
|
23
|
+
|
|
24
|
+
/** Screens that belong to the org rather than to one staff member. */
|
|
25
|
+
export const ORG_WIDE = new Set(["inbox", "org", "staff", "docs"]);
|
|
26
|
+
|
|
27
|
+
export const S = {
|
|
28
|
+
/** The org export from /api/org. Everything the brain screens read comes off this. */
|
|
29
|
+
data: null,
|
|
30
|
+
docs: null,
|
|
31
|
+
inbox: null,
|
|
32
|
+
sync: null,
|
|
33
|
+
loadedAt: null,
|
|
34
|
+
|
|
35
|
+
staffHandle: null,
|
|
36
|
+
view: "brain",
|
|
37
|
+
/** The inbox filter box. Brain and "what changed" keep their own, below. */
|
|
38
|
+
query: "",
|
|
39
|
+
|
|
40
|
+
/* What the brain's right pane is showing: a file path, or one of the memory pseudo-paths
|
|
41
|
+
`mem:*`, `mem:<section>`, `fact:<slug>`. One key, so the URL carries either. */
|
|
42
|
+
openFile: null,
|
|
43
|
+
fileQuery: "",
|
|
44
|
+
/** Which surfaces are unfolded in the brain navigator. Not in the URL: it is furniture. */
|
|
45
|
+
openSurfaces: null,
|
|
46
|
+
|
|
47
|
+
changedQuery: "",
|
|
48
|
+
changedFilter: "",
|
|
49
|
+
|
|
50
|
+
openDoc: null,
|
|
51
|
+
|
|
52
|
+
/** Which kind of run the Prompt screen is showing, and which layer of it is open. */
|
|
53
|
+
promptKind: "daily",
|
|
54
|
+
promptOpen: null,
|
|
55
|
+
|
|
56
|
+
/** Which org-layer file the Org screen is showing. */
|
|
57
|
+
orgOpen: null,
|
|
58
|
+
|
|
59
|
+
inboxFilter: "",
|
|
60
|
+
inboxStaff: "",
|
|
61
|
+
/** "" is open only, which is what an inbox is for. `closed` and `all` widen it. */
|
|
62
|
+
inboxState: "",
|
|
63
|
+
inboxOpen: null,
|
|
64
|
+
|
|
65
|
+
applyingHash: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/* The sidebar badge counts open items, not everything loaded. Once the inbox could show
|
|
69
|
+
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. */
|
|
71
|
+
export const openCount = () =>
|
|
72
|
+
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN").length;
|
|
73
|
+
|
|
74
|
+
export const staff = () => S.data.staff.find((s) => s.handle === S.staffHandle) ?? S.data.staff[0];
|
|
75
|
+
|
|
76
|
+
export function readHash() {
|
|
77
|
+
const raw = (location.hash || "").replace(/^#\/?/, "");
|
|
78
|
+
if (!raw) return null;
|
|
79
|
+
const [path, qs] = raw.split("?");
|
|
80
|
+
const [handle, view] = path.split("/");
|
|
81
|
+
const p = new URLSearchParams(qs || "");
|
|
82
|
+
return {
|
|
83
|
+
handle,
|
|
84
|
+
view,
|
|
85
|
+
q: p.get("q") || "",
|
|
86
|
+
f: p.get("f") || null,
|
|
87
|
+
k: p.get("k") || "",
|
|
88
|
+
x: p.get("x") || "",
|
|
89
|
+
s: p.get("s") || "",
|
|
90
|
+
w: p.get("w") || "",
|
|
91
|
+
t: p.get("t") || null,
|
|
92
|
+
p: p.get("p") || null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function writeHash(push) {
|
|
97
|
+
if (S.applyingHash) return;
|
|
98
|
+
if (!S.staffHandle && !ORG_WIDE.has(S.view)) return;
|
|
99
|
+
const params = new URLSearchParams();
|
|
100
|
+
const q = S.view === "brain" ? S.fileQuery : S.view === "changed" ? S.changedQuery : S.query;
|
|
101
|
+
if (q) params.set("q", q);
|
|
102
|
+
if (S.view === "brain" && S.openFile) params.set("f", S.openFile);
|
|
103
|
+
if (S.view === "docs" && S.openDoc) params.set("p", S.openDoc);
|
|
104
|
+
if (S.view === "org" && S.orgOpen) params.set("f", S.orgOpen);
|
|
105
|
+
if (S.view === "prompt") {
|
|
106
|
+
if (S.promptKind !== "daily") params.set("k", S.promptKind);
|
|
107
|
+
if (S.promptOpen) params.set("f", S.promptOpen);
|
|
108
|
+
}
|
|
109
|
+
if (S.view === "inbox") {
|
|
110
|
+
if (S.inboxFilter) params.set("s", S.inboxFilter);
|
|
111
|
+
if (S.inboxStaff) params.set("w", S.inboxStaff);
|
|
112
|
+
if (S.inboxState) params.set("x", S.inboxState);
|
|
113
|
+
if (S.inboxOpen) {
|
|
114
|
+
params.set("t", S.inboxOpen.repo + "#" + S.inboxOpen.number + ":" + S.inboxOpen.kind);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const qs = params.toString();
|
|
118
|
+
const next = "#/" + (S.staffHandle ?? "-") + "/" + S.view + (qs ? "?" + qs : "");
|
|
119
|
+
if (location.hash === next) return;
|
|
120
|
+
if (history.pushState) history[push ? "pushState" : "replaceState"](null, "", next);
|
|
121
|
+
else location.hash = next;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function applyHash() {
|
|
125
|
+
const h = readHash();
|
|
126
|
+
if (!h) return false;
|
|
127
|
+
const known = S.data.staff.some((s) => s.handle === h.handle);
|
|
128
|
+
if (known) S.staffHandle = h.handle;
|
|
129
|
+
const asked = VIEW_ALIAS[h.view] ?? h.view;
|
|
130
|
+
if (ORG_WIDE.has(asked) || VIEWS.some(([id]) => id === asked)) S.view = asked;
|
|
131
|
+
|
|
132
|
+
if (S.view === "brain") {
|
|
133
|
+
S.fileQuery = h.q;
|
|
134
|
+
S.openFile = h.f;
|
|
135
|
+
} else if (S.view === "changed") {
|
|
136
|
+
S.changedQuery = h.q;
|
|
137
|
+
} else {
|
|
138
|
+
S.query = h.q;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (S.view === "docs") S.openDoc = h.p;
|
|
142
|
+
if (S.view === "org") S.orgOpen = h.f;
|
|
143
|
+
|
|
144
|
+
if (S.view === "prompt") {
|
|
145
|
+
S.promptKind = h.k || "daily";
|
|
146
|
+
S.promptOpen = h.f;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (S.view === "inbox") {
|
|
150
|
+
S.inboxFilter = h.s;
|
|
151
|
+
S.inboxStaff = h.w;
|
|
152
|
+
S.inboxState = h.x;
|
|
153
|
+
if (h.t) {
|
|
154
|
+
const [repoNum, kind] = h.t.split(":");
|
|
155
|
+
const [repo, number] = repoNum.split("#");
|
|
156
|
+
S.inboxOpen = { repo, number: Number(number), kind: kind === "pr" ? "pr" : "issue" };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return known;
|
|
160
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* A unified diff between two strings, in the format `renderDiff` already parses.
|
|
2
|
+
*
|
|
3
|
+
* Editing a layer changes a file, but what you meant to change is the composed prompt, and
|
|
4
|
+
* those are not the same thing: a line added to one fragment can land three times or not at
|
|
5
|
+
* all. Showing the file diff would answer a question nobody asked.
|
|
6
|
+
*
|
|
7
|
+
* There is no git here, so the diff is computed in the page. Plain LCS: these are a few
|
|
8
|
+
* hundred lines of prose, and anything cleverer would be solving a problem this does not have.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function unifiedDiff(before, after, name, context = 3) {
|
|
12
|
+
const a = String(before).split("\n");
|
|
13
|
+
const b = String(after).split("\n");
|
|
14
|
+
const ops = lcsOps(a, b);
|
|
15
|
+
if (!ops.some((o) => o.op !== " ")) return "";
|
|
16
|
+
|
|
17
|
+
/* Only the neighbourhood of a change is worth printing. A whole 400-line prompt with six
|
|
18
|
+
lines highlighted is not a diff, it is the prompt again. */
|
|
19
|
+
const keep = new Set();
|
|
20
|
+
ops.forEach((o, i) => {
|
|
21
|
+
if (o.op === " ") return;
|
|
22
|
+
for (let j = Math.max(0, i - context); j <= Math.min(ops.length - 1, i + context); j++) {
|
|
23
|
+
keep.add(j);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const out = ["diff --git a/" + name + " b/" + name];
|
|
28
|
+
let i = 0;
|
|
29
|
+
let oldNo = 1;
|
|
30
|
+
let newNo = 1;
|
|
31
|
+
// Walk once, emitting a hunk each time the kept region starts again.
|
|
32
|
+
const lineNos = ops.map((o) => {
|
|
33
|
+
const at = { old: oldNo, new: newNo };
|
|
34
|
+
if (o.op !== "+") oldNo++;
|
|
35
|
+
if (o.op !== "-") newNo++;
|
|
36
|
+
return at;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
while (i < ops.length) {
|
|
40
|
+
if (!keep.has(i)) {
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
let end = i;
|
|
45
|
+
while (end + 1 < ops.length && keep.has(end + 1)) end++;
|
|
46
|
+
const rows = ops.slice(i, end + 1);
|
|
47
|
+
const oldCount = rows.filter((o) => o.op !== "+").length;
|
|
48
|
+
const newCount = rows.filter((o) => o.op !== "-").length;
|
|
49
|
+
out.push(`@@ -${lineNos[i].old},${oldCount} +${lineNos[i].new},${newCount} @@`);
|
|
50
|
+
for (const row of rows) out.push(row.op + row.text);
|
|
51
|
+
i = end + 1;
|
|
52
|
+
}
|
|
53
|
+
return out.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Longest common subsequence, as a flat list of ` `, `-` and `+` rows. */
|
|
57
|
+
function lcsOps(a, b) {
|
|
58
|
+
const n = a.length;
|
|
59
|
+
const m = b.length;
|
|
60
|
+
const grid = new Uint32Array((n + 1) * (m + 1));
|
|
61
|
+
const at = (i, j) => i * (m + 1) + j;
|
|
62
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
63
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
64
|
+
grid[at(i, j)] =
|
|
65
|
+
a[i] === b[j]
|
|
66
|
+
? grid[at(i + 1, j + 1)] + 1
|
|
67
|
+
: Math.max(grid[at(i + 1, j)], grid[at(i, j + 1)]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const ops = [];
|
|
71
|
+
let i = 0;
|
|
72
|
+
let j = 0;
|
|
73
|
+
while (i < n && j < m) {
|
|
74
|
+
if (a[i] === b[j]) {
|
|
75
|
+
ops.push({ op: " ", text: a[i] });
|
|
76
|
+
i++;
|
|
77
|
+
j++;
|
|
78
|
+
} else if (grid[at(i + 1, j)] >= grid[at(i, j + 1)]) {
|
|
79
|
+
ops.push({ op: "-", text: a[i++] });
|
|
80
|
+
} else {
|
|
81
|
+
ops.push({ op: "+", text: b[j++] });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
while (i < n) ops.push({ op: "-", text: a[i++] });
|
|
85
|
+
while (j < m) ops.push({ op: "+", text: b[j++] });
|
|
86
|
+
return ops;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How much moved, for a one-line summary beside the commit. */
|
|
90
|
+
export function diffStat(before, after) {
|
|
91
|
+
const ops = lcsOps(String(before).split("\n"), String(after).split("\n"));
|
|
92
|
+
return {
|
|
93
|
+
added: ops.filter((o) => o.op === "+").length,
|
|
94
|
+
removed: ops.filter((o) => o.op === "-").length,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* Creating a staff member's GitHub App, from the page you are already on.
|
|
2
|
+
*
|
|
3
|
+
* There is no API that creates a GitHub App. The only route is the manifest flow: post a
|
|
4
|
+
* manifest to a settings page, a human confirms, GitHub hands back a one-time code. `roster app`
|
|
5
|
+
* does that by standing up a second server on 4310; here it runs on the portal's own port, so
|
|
6
|
+
* it is one browser, one origin, and nothing extra to explain.
|
|
7
|
+
*
|
|
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 choose them, which is correct behaviour and should not
|
|
10
|
+
* be worked around. */
|
|
11
|
+
|
|
12
|
+
import { appResult, startApp } from "../api.js";
|
|
13
|
+
import { el } from "../dom.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{staff: string, name: string, scope?: "private"|"public", onDone?: () => void}} opts
|
|
17
|
+
*/
|
|
18
|
+
export function appPanel(opts) {
|
|
19
|
+
const scope = opts.scope ?? "private";
|
|
20
|
+
const box = el("div", { className: "manual" });
|
|
21
|
+
box.append(
|
|
22
|
+
el("b", {
|
|
23
|
+
textContent:
|
|
24
|
+
scope === "public"
|
|
25
|
+
? "Create the shared public identity"
|
|
26
|
+
: `Create ${opts.name}'s GitHub App`,
|
|
27
|
+
}),
|
|
28
|
+
el("p", {
|
|
29
|
+
textContent:
|
|
30
|
+
"A tab opens, GitHub asks you to confirm, and the App's id and private key go straight " +
|
|
31
|
+
"into the repo's secrets. The key is held in memory and never written to disk.",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const go = el("button", { className: "btn primary", textContent: "Create the App" });
|
|
36
|
+
const out = el("div", { style: "margin-top:9px" });
|
|
37
|
+
box.append(el("div", { className: "row" }, [go]), out);
|
|
38
|
+
|
|
39
|
+
go.onclick = async () => {
|
|
40
|
+
go.disabled = true;
|
|
41
|
+
out.replaceChildren(el("p", { className: "sub", textContent: "Preparing the hand-off…" }));
|
|
42
|
+
let started;
|
|
43
|
+
try {
|
|
44
|
+
started = await startApp(opts.staff, scope);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
go.disabled = false;
|
|
47
|
+
/* The common failure is a name already taken, and the server sends the install URL with
|
|
48
|
+
it: an App that exists is not a problem, it is a step you have already done. */
|
|
49
|
+
const note = el("div");
|
|
50
|
+
note.append(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
51
|
+
out.replaceChildren(note);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
window.open(started.start, "_blank");
|
|
56
|
+
out.replaceChildren(
|
|
57
|
+
el("p", {
|
|
58
|
+
className: "sub",
|
|
59
|
+
textContent: "Confirm it in the tab that just opened. This will update when it lands.",
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/* GitHub redirects the *other* tab back to this server, so there is no callback to await
|
|
64
|
+
here. Polling for the result the callback recorded is the only thing this tab can do. */
|
|
65
|
+
const started_at = Date.now();
|
|
66
|
+
const tick = async () => {
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await appResult(started.state);
|
|
70
|
+
} catch {
|
|
71
|
+
result = { pending: true };
|
|
72
|
+
}
|
|
73
|
+
if (result.pending) {
|
|
74
|
+
if (Date.now() - started_at > 5 * 60 * 1000) {
|
|
75
|
+
out.replaceChildren(
|
|
76
|
+
el("p", {
|
|
77
|
+
className: "sub",
|
|
78
|
+
textContent:
|
|
79
|
+
"Still waiting. If you finished in the other tab and nothing happened here, " +
|
|
80
|
+
"reload — the App may well have been created.",
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
go.disabled = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setTimeout(tick, 2000);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
go.disabled = false;
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(result.error) }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* The install is the step that most often looks done and is not, so it is the loudest
|
|
97
|
+
thing on the page once the App exists. */
|
|
98
|
+
const done = el("div");
|
|
99
|
+
done.append(
|
|
100
|
+
el("p", { textContent: `${result.slug} created, and its secrets are set.` }),
|
|
101
|
+
el("b", { textContent: "It still has to be installed." }),
|
|
102
|
+
el("p", {
|
|
103
|
+
className: "sub",
|
|
104
|
+
textContent:
|
|
105
|
+
"Grant it every tracker this staff member writes to, not just their own: the token " +
|
|
106
|
+
"is minted organisation-wide and a peer's board is where a brief lands.",
|
|
107
|
+
}),
|
|
108
|
+
el("a", {
|
|
109
|
+
className: "btn primary",
|
|
110
|
+
href: result.install,
|
|
111
|
+
target: "_blank",
|
|
112
|
+
textContent: "Install it",
|
|
113
|
+
}),
|
|
114
|
+
el("p", {
|
|
115
|
+
className: "sub",
|
|
116
|
+
textContent:
|
|
117
|
+
"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.",
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
out.replaceChildren(done);
|
|
122
|
+
opts.onDone?.();
|
|
123
|
+
};
|
|
124
|
+
setTimeout(tick, 2000);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return box;
|
|
128
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/* The brain screen.
|
|
2
|
+
*
|
|
3
|
+
* Memory and the file tree were two screens showing one thing, and both manifests already
|
|
4
|
+
* declare `memory/` as a surface with `render: memory`. So it is one screen — but one screen
|
|
5
|
+
* is not one list. The navigator is two boxes, because a parsed memory section and a file on
|
|
6
|
+
* disk are different kinds of thing and the old flat list told them apart only by typeface.
|
|
7
|
+
*
|
|
8
|
+
* The `memory/` surface's own files are folded into Memory rather than repeated under Files:
|
|
9
|
+
* `INDEX.md` is literally what "All facts" renders, and `notes/x.md` is the argument behind a
|
|
10
|
+
* fact. Listing them twice was most of what made this hard to read.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { el, kb } from "../dom.js";
|
|
14
|
+
import { icon } from "../icons.js";
|
|
15
|
+
import { S, staff, writeHash } from "../state.js";
|
|
16
|
+
import { showFile, showGallery } from "./files.js";
|
|
17
|
+
import { showMemory } from "./memory.js";
|
|
18
|
+
|
|
19
|
+
/** Files a brain browser should not offer: placeholders that hold a directory open. */
|
|
20
|
+
const NOISE = new Set([".gitkeep", ".keep", ".DS_Store"]);
|
|
21
|
+
|
|
22
|
+
const isIndex = (path) => path === "memory/INDEX.md";
|
|
23
|
+
const isNote = (path) => path.startsWith("memory/notes/");
|
|
24
|
+
|
|
25
|
+
export function viewBrain(m) {
|
|
26
|
+
const s = staff();
|
|
27
|
+
if (!S.openSurfaces) S.openSurfaces = new Map();
|
|
28
|
+
|
|
29
|
+
const allFiles = s.surfaces.flatMap((x) => x.files).filter((f) => !NOISE.has(base(f.path)));
|
|
30
|
+
const notes = allFiles.filter((f) => isNote(f.path));
|
|
31
|
+
/* What Files is actually for: everything that is not the memory index or a note behind a
|
|
32
|
+
fact, both of which Memory already renders properly. */
|
|
33
|
+
const plain = allFiles.filter((f) => !isIndex(f.path) && !isNote(f.path));
|
|
34
|
+
|
|
35
|
+
m.append(el("h1", { textContent: s.name + " · brain" }));
|
|
36
|
+
m.append(
|
|
37
|
+
el("p", {
|
|
38
|
+
className: "sub",
|
|
39
|
+
textContent:
|
|
40
|
+
s.facts.length + " facts in " + s.sections.length + " sections, " + notes.length +
|
|
41
|
+
" notes · " + plain.length + " other files across " + s.surfaces.length + " surfaces.",
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const filter = el("input", {
|
|
46
|
+
type: "search",
|
|
47
|
+
placeholder: "Search facts and files…",
|
|
48
|
+
value: S.fileQuery,
|
|
49
|
+
});
|
|
50
|
+
const count = el("span", { className: "meta" });
|
|
51
|
+
m.append(el("div", { className: "row", style: "margin-bottom:16px" }, [filter, count]));
|
|
52
|
+
|
|
53
|
+
const split = el("div", { className: "split" });
|
|
54
|
+
const tree = el("div", { className: "tree" });
|
|
55
|
+
const viewer = el("div", { className: "viewer" });
|
|
56
|
+
split.append(tree, viewer);
|
|
57
|
+
m.append(split);
|
|
58
|
+
|
|
59
|
+
filter.oninput = () => {
|
|
60
|
+
S.fileQuery = filter.value;
|
|
61
|
+
writeHash(false);
|
|
62
|
+
paintTree();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Memory is where a brain starts, so it is what you land on.
|
|
66
|
+
if (!S.openFile) S.openFile = "mem:*";
|
|
67
|
+
paintTree();
|
|
68
|
+
openBrain(S.openFile);
|
|
69
|
+
|
|
70
|
+
/* ------------------------------ selection ----------------------------- */
|
|
71
|
+
|
|
72
|
+
function pick(key) {
|
|
73
|
+
S.openFile = key;
|
|
74
|
+
writeHash(false);
|
|
75
|
+
markTree();
|
|
76
|
+
openBrain(key);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Which row is open, and — when a single fact is focused — which section holds it, so
|
|
80
|
+
opening one fact never loses your place in the list above it. */
|
|
81
|
+
function withinKey() {
|
|
82
|
+
if (!S.openFile?.startsWith("fact:")) return null;
|
|
83
|
+
const f = s.facts.find((x) => x.slug === S.openFile.slice(5));
|
|
84
|
+
return f ? "mem:" + f.section : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Applied as a row is built and again when the selection moves without a repaint, so
|
|
88
|
+
picking a fact does not rebuild the tree and throw away its scroll position. */
|
|
89
|
+
function mark(b, within) {
|
|
90
|
+
b.setAttribute("aria-current", String(b.dataset.key === S.openFile));
|
|
91
|
+
if (within && b.dataset.key === within) b.dataset.within = "true";
|
|
92
|
+
else delete b.dataset.within;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function markTree() {
|
|
96
|
+
const within = withinKey();
|
|
97
|
+
for (const o of tree.querySelectorAll(".tfile")) mark(o, within);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* -------------------------------- rows -------------------------------- */
|
|
101
|
+
|
|
102
|
+
function row(key, label, right, cls) {
|
|
103
|
+
const b = el("button", { className: "tfile" + (cls ? " " + cls : ""), title: label });
|
|
104
|
+
b.dataset.key = key;
|
|
105
|
+
b.append(el("span", { className: "t", textContent: label }));
|
|
106
|
+
if (right) b.append(el("i", { textContent: right }));
|
|
107
|
+
mark(b, withinKey());
|
|
108
|
+
b.onclick = () => pick(key);
|
|
109
|
+
return b;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A titled box in the navigator. Two of them: what the agent knows, and what it holds. */
|
|
113
|
+
function group(title, right) {
|
|
114
|
+
const g = el("div", { className: "navgroup" });
|
|
115
|
+
const h = el("div", { className: "ghead", textContent: title });
|
|
116
|
+
if (right) h.append(el("i", { textContent: right }));
|
|
117
|
+
g.append(h);
|
|
118
|
+
return g;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A folder that unfolds. Big surfaces start closed; a handful of files does not need it. */
|
|
122
|
+
function folder(g, key, label, kids, right) {
|
|
123
|
+
const open = S.openSurfaces.get(key) ?? kids.length <= 12;
|
|
124
|
+
const head = el("button", { className: "tsurface", title: label });
|
|
125
|
+
head.setAttribute("aria-expanded", String(open));
|
|
126
|
+
head.append(
|
|
127
|
+
icon("chevron", "caret"),
|
|
128
|
+
el("span", { className: "t", textContent: label }),
|
|
129
|
+
el("i", { textContent: right ?? String(kids.length) }),
|
|
130
|
+
);
|
|
131
|
+
const box = el("div", { className: "tsfiles" }, kids);
|
|
132
|
+
box.hidden = !open;
|
|
133
|
+
head.onclick = () => {
|
|
134
|
+
const now = !(S.openSurfaces.get(key) ?? kids.length <= 12);
|
|
135
|
+
S.openSurfaces.set(key, now);
|
|
136
|
+
head.setAttribute("aria-expanded", String(now));
|
|
137
|
+
box.hidden = !now;
|
|
138
|
+
};
|
|
139
|
+
g.append(head, box);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ------------------------------ the tree ------------------------------ */
|
|
143
|
+
|
|
144
|
+
function paintTree() {
|
|
145
|
+
const q = S.fileQuery.trim().toLowerCase();
|
|
146
|
+
tree.replaceChildren();
|
|
147
|
+
|
|
148
|
+
const hits = s.facts.filter((f) => matchesFact(f, q));
|
|
149
|
+
const noteHits = notes.filter((f) => !q || f.path.toLowerCase().includes(q));
|
|
150
|
+
const fileHits = plain.filter((f) => !q || f.path.toLowerCase().includes(q));
|
|
151
|
+
|
|
152
|
+
/* ---- memory ---- */
|
|
153
|
+
const mem = group("Memory", s.facts.length + " facts");
|
|
154
|
+
if (q) {
|
|
155
|
+
for (const f of hits.slice(0, 200)) mem.append(row("fact:" + f.slug, f.slug, "", "tmem"));
|
|
156
|
+
if (!hits.length) {
|
|
157
|
+
mem.append(el("div", { className: "tfile", textContent: "no facts match" }));
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
mem.append(row("mem:*", "All facts", String(s.facts.length), "tmem"));
|
|
161
|
+
for (const sec of s.sections) {
|
|
162
|
+
const n = s.facts.filter((f) => f.section === sec).length;
|
|
163
|
+
mem.append(row("mem:" + sec, sec, String(n), "tmem"));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (noteHits.length) {
|
|
167
|
+
/* Labelled, because "notes" beside a list of memory sections reads as a mystery
|
|
168
|
+
drawer. A note is the argument behind one fact, read only when that fact is in
|
|
169
|
+
play, which is what keeps the index cheap enough to read at every boot. */
|
|
170
|
+
folder(
|
|
171
|
+
mem,
|
|
172
|
+
"notes",
|
|
173
|
+
"notes/ — why a fact holds",
|
|
174
|
+
noteHits.map((f) => row(f.path, base(f.path), kb(f.bytes))),
|
|
175
|
+
String(noteHits.length),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (!q && allFiles.some((f) => isIndex(f.path))) {
|
|
179
|
+
mem.append(row("memory/INDEX.md", "INDEX.md — the raw index", "", "tmem"));
|
|
180
|
+
}
|
|
181
|
+
tree.append(mem);
|
|
182
|
+
|
|
183
|
+
/* ---- who they are ----
|
|
184
|
+
CHARTER.md and staff.yaml sit at the brain root and are in no declared surface, so
|
|
185
|
+
the portal could not show either of them. They are the two files that decide what
|
|
186
|
+
this staff member is, which made that a strange gap. */
|
|
187
|
+
if (!q) {
|
|
188
|
+
const id = group("Identity", "");
|
|
189
|
+
id.append(row("CHARTER.md", "CHARTER.md — the personality", "", "tmem"));
|
|
190
|
+
id.append(row("staff.yaml", "staff.yaml — the manifest", "", "tmem"));
|
|
191
|
+
tree.append(id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ---- files ---- */
|
|
195
|
+
if (fileHits.length) {
|
|
196
|
+
const fg = group("Files", plain.length + " files");
|
|
197
|
+
for (const surface of s.surfaces) {
|
|
198
|
+
const fs = fileHits.filter((f) => surface.files.some((x) => x.path === f.path));
|
|
199
|
+
if (!fs.length) continue;
|
|
200
|
+
const kids = fs
|
|
201
|
+
.slice(0, 400)
|
|
202
|
+
.map((f) => row(f.path, f.path.replace(surface.path, "") || f.path, kb(f.bytes)));
|
|
203
|
+
// A gallery surface earns one extra row, in words rather than the manifest's jargon.
|
|
204
|
+
if (surface.render === "gallery" && !q) {
|
|
205
|
+
kids.unshift(row("gallery:" + surface.path, "View all as a gallery", ""));
|
|
206
|
+
}
|
|
207
|
+
folder(fg, surface.path, surface.path, kids);
|
|
208
|
+
}
|
|
209
|
+
tree.append(fg);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Counted the same way the subtitle counts, so the two never disagree.
|
|
213
|
+
count.textContent = q
|
|
214
|
+
? hits.length + " facts, " + noteHits.length + " notes, " + fileHits.length + " files"
|
|
215
|
+
: s.facts.length + " facts, " + notes.length + " notes, " + plain.length + " files";
|
|
216
|
+
if (q && !hits.length && !fileHits.length && !noteHits.length) {
|
|
217
|
+
tree.replaceChildren(el("p", { className: "empty", textContent: "Nothing matches." }));
|
|
218
|
+
}
|
|
219
|
+
markTree();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ------------------------------ the pane ------------------------------ */
|
|
223
|
+
|
|
224
|
+
function openBrain(key) {
|
|
225
|
+
if (key?.startsWith("gallery:")) {
|
|
226
|
+
const surface = s.surfaces.find((x) => x.path === key.slice(8));
|
|
227
|
+
if (surface) showGallery(viewer, s, surface);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!key || key.startsWith("mem:") || key.startsWith("fact:")) {
|
|
231
|
+
showMemory(viewer, s, key || "mem:*", pick);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const f = allFiles.find((x) => x.path === key) ?? rootFile(key);
|
|
235
|
+
if (!f) {
|
|
236
|
+
viewer.replaceChildren(
|
|
237
|
+
el("p", { className: "empty", textContent: "That file is not in this brain." }),
|
|
238
|
+
);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
showFile(viewer, s, f, pick);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const base = (path) => path.split("/").pop();
|
|
246
|
+
|
|
247
|
+
/* A file at the brain root, which no surface declares. `bytes` and `modified` are only used
|
|
248
|
+
for the header line, and the renderer reads the file itself. */
|
|
249
|
+
const ROOT_FILES = new Set(["CHARTER.md", "staff.yaml", "README.md"]);
|
|
250
|
+
function rootFile(key) {
|
|
251
|
+
if (!ROOT_FILES.has(key)) return null;
|
|
252
|
+
return { path: key, ext: key.split(".").pop().toLowerCase(), bytes: 0, modified: new Date().toISOString() };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function matchesFact(f, q) {
|
|
256
|
+
if (!q) return true;
|
|
257
|
+
return (f.slug + " " + f.statement + " " + (f.consequence ?? "") + " " + (f.section ?? ""))
|
|
258
|
+
.toLowerCase()
|
|
259
|
+
.includes(q);
|
|
260
|
+
}
|