@nanocollective/roster 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +70 -84
- package/dist/cli.js +4817 -2374
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- package/docs/architecture.md +13 -5
- package/docs/charters/analyst.md +65 -0
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/community.md +63 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/designer.md +65 -0
- package/docs/charters/devops.md +65 -0
- package/docs/charters/pm.md +70 -0
- package/docs/charters/qa.md +65 -0
- package/docs/charters/support.md +60 -0
- package/docs/charters/writer.md +63 -0
- package/docs/commands.md +95 -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 +4 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +133 -79
- 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 +95 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +76 -11
- package/docs/portal.md +290 -49
- package/docs/prompts.md +77 -11
- package/docs/security.md +51 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +17 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +46 -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/briefs/priorities.md +46 -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 +245 -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 +32 -11
- package/templates/portal/css/inbox.css +117 -14
- package/templates/portal/css/layout.css +114 -41
- package/templates/portal/css/markdown.css +57 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +126 -39
- package/templates/portal/index.html +25 -3
- package/templates/portal/js/api.js +74 -4
- package/templates/portal/js/app.js +156 -14
- package/templates/portal/js/dialog.js +129 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +45 -1
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +59 -8
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/checklist.js +29 -10
- package/templates/portal/js/views/credential.js +84 -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/hire.js +583 -0
- package/templates/portal/js/views/inbox.js +957 -126
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +124 -104
- package/templates/portal/js/views/orgedit.js +234 -0
- package/templates/portal/js/views/paste.js +87 -21
- package/templates/portal/js/views/prompt.js +61 -67
- package/templates/portal/js/views/repos.js +20 -15
- 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 +257 -75
- package/templates/portal/js/views/staff.js +157 -182
- package/templates/portal/js/views/todo.js +62 -0
- 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,46 @@
|
|
|
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,
|
|
13
|
-
import { el, esc,
|
|
17
|
+
import { getFile, getOrgLayer } from "../api.js";
|
|
18
|
+
import { el, esc, kb, skeleton } from "../dom.js";
|
|
14
19
|
import { mdlite } from "../md.js";
|
|
15
|
-
import { S, writeHash } from "../state.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
import { humansOf, S, writeHash } from "../state.js";
|
|
21
|
+
import { yamlPre } from "../yaml.js";
|
|
22
|
+
import { businessForm, fileModes, prioritiesForm } from "./orgedit.js";
|
|
23
|
+
import { repoPicker } from "./repos.js";
|
|
24
|
+
|
|
25
|
+
/* What the files roster itself ships are for, in one line each. A filename says nothing about
|
|
26
|
+
which one to open; anything a tenant has added of its own falls back to its first heading. */
|
|
27
|
+
const KNOWN = {
|
|
28
|
+
"org.yaml": "The people, repos, defaults and coding agent.",
|
|
29
|
+
"org/business.md": "What the business is. Every staff member reads it.",
|
|
30
|
+
"org/priorities.md": "This month's priorities, in order, and what's out of scope.",
|
|
31
|
+
"org/operating.md": "What staff do on their own, what they ask about, and how a run starts and ends.",
|
|
32
|
+
"org/guardrails.md": "Rules every staff member must follow.",
|
|
33
|
+
"org/voice.md": "How staff write.",
|
|
34
|
+
"prompts/daily.md": "The instructions for a scheduled run.",
|
|
35
|
+
"prompts/mention.md": "The instructions when you @-mention someone.",
|
|
36
|
+
"prompts/_identity.md": "Who staff post as.",
|
|
37
|
+
"prompts/_paths.md": "Where files are during a run.",
|
|
38
|
+
"prompts/_inflight.md": "Pull requests people have open, so staff don't work on the same files.",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const GROUPS = [
|
|
42
|
+
["org", "Org files", "Every staff member reads these."],
|
|
43
|
+
["prompts", "Prompts", "Roster's run instructions. Upgrades may change them."],
|
|
25
44
|
];
|
|
26
45
|
|
|
27
46
|
export function viewOrg(m) {
|
|
@@ -31,50 +50,96 @@ export function viewOrg(m) {
|
|
|
31
50
|
el("p", {
|
|
32
51
|
className: "sub",
|
|
33
52
|
textContent:
|
|
34
|
-
"
|
|
35
|
-
org.staff.length + " of them on their next run.",
|
|
53
|
+
"Files every staff member reads. Changes apply from their next run.",
|
|
36
54
|
}),
|
|
37
55
|
);
|
|
38
56
|
|
|
39
57
|
const facts = el("div", { className: "grid", style: "margin-bottom:18px" });
|
|
40
|
-
const
|
|
58
|
+
const humans = humansOf();
|
|
41
59
|
facts.append(
|
|
42
60
|
kv("Organisation", esc(org.org), org.name),
|
|
43
|
-
|
|
61
|
+
/* Plural on purpose. An org can answer to more than one person, and a card that names one
|
|
62
|
+
of two is worse than one that names neither: it reads as the only one who counts. */
|
|
63
|
+
kv(
|
|
64
|
+
"They answer to",
|
|
65
|
+
humans.length
|
|
66
|
+
? humans.map((h) => esc(h.name ?? h.github)).join(", ")
|
|
67
|
+
: "nobody",
|
|
68
|
+
humans.length
|
|
69
|
+
? humans.map((h) => (h.github ? "@" + h.github : "no github handle")).join(" · ")
|
|
70
|
+
: "org.yaml names no human, so nothing can wake an agent",
|
|
71
|
+
),
|
|
44
72
|
kv("Staff", String(org.staff.length), org.staff.map((s) => s.handle).join(", ")),
|
|
45
73
|
);
|
|
46
74
|
m.append(facts);
|
|
47
75
|
|
|
76
|
+
/* Marking a product repo after setup. The picker only lived on the setup screen, so a repo
|
|
77
|
+
created later meant typing `role: product` into org.yaml by hand. Folded, because it asks
|
|
78
|
+
GitHub for every repo in the org and most visits here are to read a file. */
|
|
79
|
+
const repoPane = el("div");
|
|
80
|
+
const repos = el("button", { className: "ghbtn", textContent: "Add a product repo" });
|
|
81
|
+
repos.onclick = () => {
|
|
82
|
+
if (repoPane.children.length) {
|
|
83
|
+
repoPane.replaceChildren();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
repoPane.replaceChildren(repoPicker({ org: org.org, onAdded: () => open("org.yaml") }));
|
|
87
|
+
};
|
|
88
|
+
m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [repos]), repoPane);
|
|
89
|
+
|
|
48
90
|
const split = el("div", { className: "split" });
|
|
49
91
|
const tree = el("div", { className: "tree" });
|
|
50
92
|
const viewer = el("div", { className: "viewer" });
|
|
51
93
|
split.append(tree, viewer);
|
|
52
94
|
m.append(split);
|
|
53
95
|
|
|
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
96
|
const full = (path) => S.data.opsName + "/" + path;
|
|
74
97
|
|
|
75
|
-
|
|
98
|
+
tree.replaceChildren(...skeleton("row", 6));
|
|
99
|
+
getOrgLayer()
|
|
100
|
+
.then(({ files }) => {
|
|
101
|
+
S.orgFiles = files ?? [];
|
|
102
|
+
paintTree();
|
|
103
|
+
const first = S.orgFiles[0]?.path;
|
|
104
|
+
open(S.orgFiles.some((f) => f.path === S.orgOpen) ? S.orgOpen : first);
|
|
105
|
+
})
|
|
106
|
+
.catch((e) => {
|
|
107
|
+
tree.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function why(file) {
|
|
111
|
+
return KNOWN[file.path] ?? file.title ?? "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function paintTree() {
|
|
115
|
+
tree.replaceChildren();
|
|
116
|
+
if (!S.orgFiles.length) {
|
|
117
|
+
tree.append(el("p", { className: "empty", textContent: "No org files here." }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const [id, heading, note] of GROUPS) {
|
|
121
|
+
const mine = S.orgFiles.filter((f) => (f.group ?? "org") === id);
|
|
122
|
+
if (!mine.length) continue;
|
|
123
|
+
const group = el("div", { className: "navgroup" });
|
|
124
|
+
group.append(el("div", { className: "ghead", textContent: heading, title: note }));
|
|
125
|
+
for (const f of mine) {
|
|
126
|
+
const b = el("button", { className: "tfile tlayer", title: why(f) });
|
|
127
|
+
b.dataset.key = f.path;
|
|
128
|
+
const t = el("span", { className: "t" });
|
|
129
|
+
t.append(
|
|
130
|
+
el("span", { className: "lname", textContent: f.path.replace(/^(org|prompts)\//, "") }),
|
|
131
|
+
el("span", { className: "lrepo", textContent: why(f) }),
|
|
132
|
+
);
|
|
133
|
+
b.append(t);
|
|
134
|
+
b.onclick = () => open(f.path);
|
|
135
|
+
group.append(b);
|
|
136
|
+
}
|
|
137
|
+
tree.append(group);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
76
140
|
|
|
77
141
|
function open(path) {
|
|
142
|
+
if (!path) return;
|
|
78
143
|
S.orgOpen = path;
|
|
79
144
|
writeHash(false);
|
|
80
145
|
for (const o of tree.querySelectorAll(".tfile")) {
|
|
@@ -84,7 +149,7 @@ export function viewOrg(m) {
|
|
|
84
149
|
}
|
|
85
150
|
|
|
86
151
|
async function show(path) {
|
|
87
|
-
viewer.replaceChildren(
|
|
152
|
+
viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 7));
|
|
88
153
|
let text;
|
|
89
154
|
try {
|
|
90
155
|
text = await getFile(full(path));
|
|
@@ -97,72 +162,27 @@ export function viewOrg(m) {
|
|
|
97
162
|
|
|
98
163
|
function render(path, text) {
|
|
99
164
|
viewer.replaceChildren();
|
|
100
|
-
const
|
|
101
|
-
const head = el("div", { className: "row", style: "margin-bottom:4px" });
|
|
165
|
+
const head = el("div", { className: "row filehead" });
|
|
102
166
|
head.append(el("span", { className: "meta", textContent: full(path) + " · " + kb(text.length) }));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
viewer.append(
|
|
116
|
-
el("div", { className: "row", style: "margin-bottom:10px" }, [
|
|
117
|
-
el("span", { className: "meta", textContent: full(path) }),
|
|
118
|
-
]),
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
const ta = el("textarea", { value: text, className: "editor" });
|
|
122
|
-
viewer.append(ta);
|
|
123
|
-
grow(ta);
|
|
124
|
-
ta.addEventListener("input", () => grow(ta));
|
|
125
|
-
|
|
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
|
-
cancel.onclick = () => render(path, text);
|
|
130
|
-
viewer.append(el("div", { className: "row", style: "margin-top:10px" }, [save, cancel, status]));
|
|
167
|
+
const file = S.orgFiles.find((f) => f.path === path) ?? { path };
|
|
168
|
+
|
|
169
|
+
const isYaml = /\.ya?ml$/.test(path);
|
|
170
|
+
/* Every file opens on Read, with the ways to change it as tabs beside it: the same ones
|
|
171
|
+
Getting started offers, because a file is no easier to write after setup than during it.
|
|
172
|
+
org.yaml is checked by the server before it is written, so a bad one is refused. */
|
|
173
|
+
viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
|
|
174
|
+
const read = {
|
|
175
|
+
label: "Read",
|
|
176
|
+
node: isYaml ? yamlPre(text) : el("div", { className: "md doc", innerHTML: mdlite(text) }),
|
|
177
|
+
};
|
|
178
|
+
const o = { path: full(path), name: S.data.name, text, before: [read], onSaved: () => show(path) };
|
|
131
179
|
viewer.append(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"
|
|
137
|
-
(path.endsWith(".yaml")
|
|
138
|
-
? " org.yaml is checked before it is written: bad YAML, or a missing org or name, is refused rather than committed."
|
|
139
|
-
: ""),
|
|
140
|
-
}),
|
|
180
|
+
path === "org/business.md"
|
|
181
|
+
? businessForm(o)
|
|
182
|
+
: path === "org/priorities.md"
|
|
183
|
+
? prioritiesForm(o)
|
|
184
|
+
: fileModes({ ...o, lang: isYaml ? "yaml" : "md" }),
|
|
141
185
|
);
|
|
142
|
-
|
|
143
|
-
save.onclick = async () => {
|
|
144
|
-
if (ta.value === text) {
|
|
145
|
-
status.textContent = "nothing changed";
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
if (!confirm("Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.")) return;
|
|
149
|
-
save.disabled = cancel.disabled = true;
|
|
150
|
-
status.textContent = "committing…";
|
|
151
|
-
status.className = "meta";
|
|
152
|
-
try {
|
|
153
|
-
const r = await post({ path: full(path), text: ta.value }, "/api/save");
|
|
154
|
-
status.textContent = r.pushed
|
|
155
|
-
? "committed and pushed · " + r.sha
|
|
156
|
-
: "committed " + r.sha + ", but the push failed: " + (r.note ?? "");
|
|
157
|
-
status.className = r.pushed ? "meta ok" : "meta warn";
|
|
158
|
-
render(path, ta.value);
|
|
159
|
-
} catch (e) {
|
|
160
|
-
// A refused org.yaml is the interesting case: the reason is the whole message.
|
|
161
|
-
status.textContent = e.message;
|
|
162
|
-
status.className = "meta err";
|
|
163
|
-
save.disabled = cancel.disabled = false;
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
186
|
}
|
|
167
187
|
}
|
|
168
188
|
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* Ways to write the two org files a person has to write, shared by Getting started and the Org
|
|
2
|
+
* screen: plain questions, an AI interview, or the file itself. Each is one tab of a switcher,
|
|
3
|
+
* so the choice is visible rather than folded under a Save button. */
|
|
4
|
+
|
|
5
|
+
import { fileUrl, saveFile } from "../api.js";
|
|
6
|
+
import { el, grow } from "../dom.js";
|
|
7
|
+
import { highlighted } from "../mdedit.js";
|
|
8
|
+
import { paste } from "./paste.js";
|
|
9
|
+
|
|
10
|
+
const BUSINESS_QUESTIONS = [
|
|
11
|
+
["line", "What does it do, in one line?", "What this business does, in one line", true],
|
|
12
|
+
["who", "Who is it for?", "Who the customers are, specifically", true],
|
|
13
|
+
["key", "What does everything depend on?", "The one fact everything else follows from", false],
|
|
14
|
+
["true", "What's already true? What's built, measured or tried?", "What is already true", false],
|
|
15
|
+
["not", "What should staff never decide on their own?", "What is not ours to decide", false],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const val = (input) => String(input.value ?? "").trim();
|
|
19
|
+
|
|
20
|
+
/* A file still in the shape it shipped in holds the questions, not answers, and pre-filling the
|
|
21
|
+
fields with "Not a segment. The person…" reads as if somebody had already answered them. */
|
|
22
|
+
const isStub = (text) => /This file is a stub|Or answer these by hand/.test(text);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* org/business.md as five questions. With `text`, the answers already in the file fill the
|
|
26
|
+
* fields, so changing one answer does not mean retyping the other four.
|
|
27
|
+
* @param {{path: string, name: string, text?: string, onSaved: () => void, before?: object[]}} o
|
|
28
|
+
* `before` puts extra tabs ahead of the three, which is how the Org screen adds Read.
|
|
29
|
+
*/
|
|
30
|
+
export function businessForm(o) {
|
|
31
|
+
const form = el("div");
|
|
32
|
+
const known = isStub(o.text ?? "") ? new Map() : sections(o.text ?? "");
|
|
33
|
+
const fields = BUSINESS_QUESTIONS.map(([id, label, heading, required]) => {
|
|
34
|
+
const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
|
|
35
|
+
const had = known.get(heading.toLowerCase());
|
|
36
|
+
if (had) input.value = had.replace(/^>\s?/gm, "");
|
|
37
|
+
return { id, input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: label + (required ? "" : " (optional)") }), input]) };
|
|
38
|
+
});
|
|
39
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
40
|
+
const out = el("div");
|
|
41
|
+
form.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
|
|
42
|
+
|
|
43
|
+
save.onclick = async () => {
|
|
44
|
+
const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !val(f.input));
|
|
45
|
+
if (missing) {
|
|
46
|
+
missing.input.focus?.();
|
|
47
|
+
out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const text =
|
|
51
|
+
`# What ${o.name} is\n\n` +
|
|
52
|
+
fields
|
|
53
|
+
.map((f, i) => [BUSINESS_QUESTIONS[i][2], val(f.input)])
|
|
54
|
+
.filter(([, v]) => v)
|
|
55
|
+
.map(([h, v]) => `## ${h}\n\n${v}\n`)
|
|
56
|
+
.join("\n");
|
|
57
|
+
await saveAndSay(save, out, o.path, text, "portal: write org/business.md", o.onSaved);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return modes([
|
|
61
|
+
...(o.before ?? []),
|
|
62
|
+
{ label: "Answer questions", node: form },
|
|
63
|
+
{ label: "Let an AI interview you", node: paste({ kind: "discover", title: "", onSaved: o.onSaved }) },
|
|
64
|
+
rawMode(o.path, o.onSaved),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** org/priorities.md as three lines and an out-of-scope list, filled from the file if it has them. */
|
|
69
|
+
export function prioritiesForm(o) {
|
|
70
|
+
const form = el("div");
|
|
71
|
+
const { items, outs } = isStub(o.text ?? "") ? { items: [], outs: [] } : parsePriorities(o.text ?? "");
|
|
72
|
+
const ranks = [1, 2, 3].map((i) => {
|
|
73
|
+
const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
|
|
74
|
+
if (items[i - 1]) input.value = items[i - 1];
|
|
75
|
+
return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
|
|
76
|
+
});
|
|
77
|
+
const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
|
|
78
|
+
if (outs.length) scope.value = outs.join("\n");
|
|
79
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
80
|
+
const out = el("div");
|
|
81
|
+
form.append(
|
|
82
|
+
...ranks.map((r) => r.wrap),
|
|
83
|
+
el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
|
|
84
|
+
el("div", { className: "row" }, [save]),
|
|
85
|
+
out,
|
|
86
|
+
);
|
|
87
|
+
save.onclick = async () => {
|
|
88
|
+
const list = ranks.map((r) => val(r.input)).filter(Boolean);
|
|
89
|
+
if (!list.length) {
|
|
90
|
+
ranks[0].input.focus?.();
|
|
91
|
+
out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const scoped = val(scope).split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
|
|
95
|
+
const text =
|
|
96
|
+
"## What matters this month\n\n### Priorities, in order\n\n" +
|
|
97
|
+
list.map((t, i) => `${i + 1}. ${t}`).join("\n") +
|
|
98
|
+
"\n" +
|
|
99
|
+
(scoped.length ? "\n### Out of scope this month\n\n" + scoped.map((t) => `- ${t}`).join("\n") + "\n" : "");
|
|
100
|
+
await saveAndSay(save, out, o.path, text, "portal: write org/priorities.md", o.onSaved);
|
|
101
|
+
};
|
|
102
|
+
return modes([
|
|
103
|
+
...(o.before ?? []),
|
|
104
|
+
{ label: "Fill in", node: form },
|
|
105
|
+
{ label: "Let an AI interview you", node: paste({ kind: "priorities", title: "", onSaved: o.onSaved }) },
|
|
106
|
+
rawMode(o.path, o.onSaved),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Any org file as highlighted text, with Read ahead of it on the Org screen. */
|
|
111
|
+
export function fileModes(o) {
|
|
112
|
+
return modes([...(o.before ?? []), rawMode(o.path, o.onSaved, o.lang)]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The file itself, for editing what is already there rather than starting again.
|
|
117
|
+
* @param {{label?: string, note?: string, read?: () => Promise<string>}} o
|
|
118
|
+
* `read` starts the editor from other text than the file, once, so switching tabs does not
|
|
119
|
+
* throw away what was typed. `note` is a line above the editor.
|
|
120
|
+
*/
|
|
121
|
+
export function rawMode(path, onSaved, lang = "md", o = {}) {
|
|
122
|
+
const node = el("div");
|
|
123
|
+
if (o.note) node.append(el("p", { className: "sub", textContent: o.note }));
|
|
124
|
+
const ta = el("textarea", { className: "pastebox" });
|
|
125
|
+
ta.rows = 12;
|
|
126
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
127
|
+
const out = el("div");
|
|
128
|
+
const hl = highlighted(ta, lang);
|
|
129
|
+
node.append(hl, el("div", { className: "row" }, [save]), out);
|
|
130
|
+
ta.addEventListener("input", () => grow(ta));
|
|
131
|
+
const commit = () =>
|
|
132
|
+
saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
|
|
133
|
+
save.onclick = commit;
|
|
134
|
+
// ⌘S is what a person's hands do in a text box.
|
|
135
|
+
ta.addEventListener("keydown", (e) => {
|
|
136
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
commit();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// Read when first shown, so it shows the file as it is then, not as it was at page load.
|
|
142
|
+
let read = false;
|
|
143
|
+
const load = () => {
|
|
144
|
+
if (o.read && read) return;
|
|
145
|
+
read = true;
|
|
146
|
+
return (
|
|
147
|
+
o.read
|
|
148
|
+
? o.read()
|
|
149
|
+
: // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
|
|
150
|
+
fetch(fileUrl(path), { cache: "no-store" }).then((r) => (r.ok ? r.text() : ""))
|
|
151
|
+
).then((text) => {
|
|
152
|
+
ta.value = text;
|
|
153
|
+
grow(ta);
|
|
154
|
+
hl.repaint();
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
return { label: o.label ?? "Edit the file", node, onShow: load };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Ways to do one thing, as a switcher across the top: one shown at a time. */
|
|
161
|
+
export function modes(list) {
|
|
162
|
+
const box = el("div");
|
|
163
|
+
const bar = el("div", { className: "modes" });
|
|
164
|
+
const panes = el("div");
|
|
165
|
+
const buttons = list.map((m, i) => {
|
|
166
|
+
const b = el("button", { className: "mode", textContent: m.label });
|
|
167
|
+
b.onclick = () => show(i);
|
|
168
|
+
return b;
|
|
169
|
+
});
|
|
170
|
+
bar.append(...buttons);
|
|
171
|
+
box.append(bar, panes);
|
|
172
|
+
function show(i) {
|
|
173
|
+
buttons.forEach((b, j) => b.classList.toggle("on", i === j));
|
|
174
|
+
panes.replaceChildren(list[i].node);
|
|
175
|
+
list[i].onShow?.();
|
|
176
|
+
}
|
|
177
|
+
show(0);
|
|
178
|
+
return box;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function saveAndSay(btn, out, path, text, message, onSaved) {
|
|
182
|
+
btn.disabled = true;
|
|
183
|
+
const label = btn.textContent;
|
|
184
|
+
btn.textContent = "Saving…";
|
|
185
|
+
try {
|
|
186
|
+
const r = await saveFile(path, text, message);
|
|
187
|
+
out.replaceChildren(
|
|
188
|
+
el("p", {
|
|
189
|
+
className: r.pushed ? "sub" : "err",
|
|
190
|
+
textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
onSaved?.();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
196
|
+
} finally {
|
|
197
|
+
btn.disabled = false;
|
|
198
|
+
btn.textContent = label;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `## Heading` → the text under it, by lower-cased heading. */
|
|
203
|
+
function sections(text) {
|
|
204
|
+
const out = new Map();
|
|
205
|
+
let key = null;
|
|
206
|
+
let buf = [];
|
|
207
|
+
const flush = () => key && out.set(key, buf.join("\n").trim());
|
|
208
|
+
for (const line of text.split("\n")) {
|
|
209
|
+
const m = /^##\s+(.*)$/.exec(line);
|
|
210
|
+
if (m) {
|
|
211
|
+
flush();
|
|
212
|
+
key = m[1].trim().toLowerCase();
|
|
213
|
+
buf = [];
|
|
214
|
+
} else if (key) buf.push(line);
|
|
215
|
+
}
|
|
216
|
+
flush();
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parsePriorities(text) {
|
|
221
|
+
const items = [];
|
|
222
|
+
const outs = [];
|
|
223
|
+
let where = "";
|
|
224
|
+
for (const line of text.split("\n")) {
|
|
225
|
+
if (/^#+\s.*priorit/i.test(line)) where = "p";
|
|
226
|
+
else if (/^#+\s.*out of scope/i.test(line)) where = "o";
|
|
227
|
+
else if (/^#/.test(line)) where = "";
|
|
228
|
+
const n = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
229
|
+
const b = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
230
|
+
if (where === "p" && n) items.push(n[1].trim());
|
|
231
|
+
if (where === "o" && b) outs.push(b[1].trim());
|
|
232
|
+
}
|
|
233
|
+
return { items, outs };
|
|
234
|
+
}
|