@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.50
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 +4833 -2752
- package/docs/README.md +9 -6
- package/docs/agents.md +24 -20
- 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 +93 -7
- package/docs/concepts.md +61 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +10 -2
- package/docs/export.md +2 -0
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +128 -78
- 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 -123
- package/docs/memory.md +21 -3
- package/docs/org-yaml.md +40 -2
- package/docs/portal.md +165 -48
- package/docs/prompts.md +31 -4
- package/docs/security.md +37 -5
- package/docs/session-workflow.md +63 -17
- package/docs/staff-yaml.md +30 -3
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +28 -0
- package/package.json +18 -20
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
- 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 +4 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +236 -15
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +31 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +43 -4
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +37 -9
- package/templates/ops/prompts/mention.md +21 -0
- package/templates/ops/run-record.mjs +146 -0
- package/templates/portal/css/base.css +167 -73
- package/templates/portal/css/brain.css +23 -20
- package/templates/portal/css/diff.css +10 -9
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +13 -11
- package/templates/portal/css/home.css +93 -0
- package/templates/portal/css/inbox.css +45 -25
- package/templates/portal/css/layout.css +90 -46
- package/templates/portal/css/markdown.css +36 -14
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +116 -34
- package/templates/portal/index.html +21 -9
- package/templates/portal/js/api.js +44 -4
- package/templates/portal/js/app.js +94 -9
- package/templates/portal/js/dialog.js +83 -0
- package/templates/portal/js/homesort.js +174 -0
- package/templates/portal/js/icons.js +37 -0
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/md.js +5 -2
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/readiness.js +70 -0
- package/templates/portal/js/refresh.js +10 -2
- package/templates/portal/js/state.js +11 -5
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/brain.js +1 -1
- package/templates/portal/js/views/checklist.js +10 -4
- package/templates/portal/js/views/credential.js +84 -0
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +17 -4
- package/templates/portal/js/views/hire.js +593 -0
- package/templates/portal/js/views/home.js +546 -0
- package/templates/portal/js/views/inbox.js +226 -70
- package/templates/portal/js/views/org.js +46 -106
- 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 +11 -4
- 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 +170 -0
- package/templates/portal/js/views/setup.js +261 -75
- package/templates/portal/js/views/staff.js +170 -243
- package/templates/portal/js/views/todo.js +62 -0
|
@@ -14,34 +14,33 @@
|
|
|
14
14
|
* wrong. The server validates it before writing.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { getFile, getOrgLayer
|
|
18
|
-
import { el, esc,
|
|
19
|
-
import { icon } from "../icons.js";
|
|
17
|
+
import { getFile, getOrgLayer } from "../api.js";
|
|
18
|
+
import { el, esc, kb, skeleton } from "../dom.js";
|
|
20
19
|
import { mdlite } from "../md.js";
|
|
21
20
|
import { humansOf, S, writeHash } from "../state.js";
|
|
22
21
|
import { yamlPre } from "../yaml.js";
|
|
22
|
+
import { businessForm, fileModes, prioritiesForm } from "./orgedit.js";
|
|
23
|
+
import { repoPicker } from "./repos.js";
|
|
23
24
|
|
|
24
25
|
/* What the files roster itself ships are for, in one line each. A filename says nothing about
|
|
25
26
|
which one to open; anything a tenant has added of its own falls back to its first heading. */
|
|
26
27
|
const KNOWN = {
|
|
27
|
-
"org.yaml": "
|
|
28
|
-
"org/business.md":
|
|
29
|
-
|
|
30
|
-
"org/operating.md":
|
|
31
|
-
|
|
32
|
-
"org/
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
"prompts/
|
|
37
|
-
"prompts/
|
|
38
|
-
"prompts/_identity.md": "Who they post as, and what wakes you.",
|
|
39
|
-
"prompts/_paths.md": "Where everything is in the runner's checkout.",
|
|
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.",
|
|
40
39
|
};
|
|
41
40
|
|
|
42
41
|
const GROUPS = [
|
|
43
|
-
["org", "
|
|
44
|
-
["prompts", "
|
|
42
|
+
["org", "Org files", "Every staff member reads these."],
|
|
43
|
+
["prompts", "Prompts", "Roster's run instructions. Upgrades may change them."],
|
|
45
44
|
];
|
|
46
45
|
|
|
47
46
|
export function viewOrg(m) {
|
|
@@ -51,8 +50,7 @@ export function viewOrg(m) {
|
|
|
51
50
|
el("p", {
|
|
52
51
|
className: "sub",
|
|
53
52
|
textContent:
|
|
54
|
-
"
|
|
55
|
-
org.staff.length + " of them on their next run.",
|
|
53
|
+
"Files every staff member reads. Changes apply from their next run.",
|
|
56
54
|
}),
|
|
57
55
|
);
|
|
58
56
|
|
|
@@ -75,6 +73,20 @@ export function viewOrg(m) {
|
|
|
75
73
|
);
|
|
76
74
|
m.append(facts);
|
|
77
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
|
+
|
|
78
90
|
const split = el("div", { className: "split" });
|
|
79
91
|
const tree = el("div", { className: "tree" });
|
|
80
92
|
const viewer = el("div", { className: "viewer" });
|
|
@@ -152,97 +164,25 @@ export function viewOrg(m) {
|
|
|
152
164
|
viewer.replaceChildren();
|
|
153
165
|
const head = el("div", { className: "row filehead" });
|
|
154
166
|
head.append(el("span", { className: "meta", textContent: full(path) + " · " + kb(text.length) }));
|
|
155
|
-
/* The edit control used to be a word in a row of grey text, which is why "there is no easy
|
|
156
|
-
way to edit the org docs" was a fair thing to say about a screen that could edit them. */
|
|
157
|
-
const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
|
|
158
|
-
edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
|
|
159
|
-
head.append(edit);
|
|
160
167
|
const file = S.orgFiles.find((f) => f.path === path) ?? { path };
|
|
161
|
-
viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
|
|
162
|
-
|
|
163
|
-
if (path.endsWith(".yaml") || path.endsWith(".yml")) {
|
|
164
|
-
viewer.append(yamlPre(text));
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
viewer.append(el("div", { className: "md doc", innerHTML: mdlite(text) }));
|
|
168
|
-
}
|
|
169
168
|
|
|
170
|
-
function editor(path, text) {
|
|
171
|
-
viewer.replaceChildren();
|
|
172
169
|
const isYaml = /\.ya?ml$/.test(path);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const ta = el("textarea", { value: text, className: "editor" });
|
|
183
|
-
viewer.append(ta);
|
|
184
|
-
grow(ta);
|
|
185
|
-
ta.addEventListener("input", () => grow(ta));
|
|
186
|
-
// ⌘S is what a person's hands do in a text box. Without it, saving means finding a button
|
|
187
|
-
// above a screenful of textarea you have just scrolled past.
|
|
188
|
-
ta.addEventListener("keydown", (e) => {
|
|
189
|
-
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
190
|
-
e.preventDefault();
|
|
191
|
-
commit();
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
ta.focus?.();
|
|
195
|
-
|
|
196
|
-
cancel.onclick = () => render(path, text);
|
|
197
|
-
save.onclick = commit;
|
|
198
|
-
|
|
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) };
|
|
199
179
|
viewer.append(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
(isYaml
|
|
206
|
-
? " org.yaml is checked before it is written: bad YAML, or a missing org or name, is refused rather than committed."
|
|
207
|
-
: ""),
|
|
208
|
-
}),
|
|
180
|
+
path === "org/business.md"
|
|
181
|
+
? businessForm(o)
|
|
182
|
+
: path === "org/priorities.md"
|
|
183
|
+
? prioritiesForm(o)
|
|
184
|
+
: fileModes({ ...o, lang: isYaml ? "yaml" : "md" }),
|
|
209
185
|
);
|
|
210
|
-
|
|
211
|
-
async function commit() {
|
|
212
|
-
if (ta.value === text) {
|
|
213
|
-
status.textContent = "nothing changed";
|
|
214
|
-
status.className = "meta";
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
/* Only the manifest asks. A prose edit is one commit to revert and the agents pick it up
|
|
218
|
-
on their next run; org.yaml is the file that can stop every prompt composing, and the
|
|
219
|
-
portal has just told you it validates it. Asking every time taught people to click
|
|
220
|
-
through the question, which is worse than not asking. */
|
|
221
|
-
if (
|
|
222
|
-
isYaml &&
|
|
223
|
-
!confirm(
|
|
224
|
-
"Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.",
|
|
225
|
-
)
|
|
226
|
-
) {
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
save.disabled = cancel.disabled = true;
|
|
230
|
-
status.textContent = "committing…";
|
|
231
|
-
status.className = "meta";
|
|
232
|
-
try {
|
|
233
|
-
const r = await post({ path: full(path), text: ta.value }, "/api/save");
|
|
234
|
-
status.textContent = r.pushed
|
|
235
|
-
? "committed and pushed · " + r.sha
|
|
236
|
-
: "committed " + r.sha + ", but the push failed: " + (r.note ?? "");
|
|
237
|
-
status.className = r.pushed ? "meta ok" : "meta warn";
|
|
238
|
-
render(path, ta.value);
|
|
239
|
-
} catch (e) {
|
|
240
|
-
// A refused org.yaml is the interesting case: the reason is the whole message.
|
|
241
|
-
status.textContent = e.message;
|
|
242
|
-
status.className = "meta err";
|
|
243
|
-
save.disabled = cancel.disabled = false;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
186
|
}
|
|
247
187
|
}
|
|
248
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
|
+
}
|
|
@@ -7,19 +7,27 @@
|
|
|
7
7
|
* Nothing here writes. The panel parses, shows a diff, and waits for a second click. */
|
|
8
8
|
|
|
9
9
|
import { getBrief, parsePaste, saveFile } from "../api.js";
|
|
10
|
+
import { mdlite } from "../md.js";
|
|
10
11
|
import { el, toClipboard } from "../dom.js";
|
|
11
12
|
import { unifiedDiff, diffStat } from "../textdiff.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* @param {{kind: string, staff?: string, title?: string, onSaved?: () => void}} opts
|
|
15
|
+
* @param {{kind: string, staff?: string, title?: string, about?: string, onSaved?: () => void}} opts
|
|
16
|
+
* `about` is a sentence on what the role does, added to a charter brief.
|
|
15
17
|
*/
|
|
16
18
|
export function paste(opts) {
|
|
17
19
|
const box = el("div", { className: "paste" });
|
|
18
|
-
box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
|
|
20
|
+
if (opts.title !== "") box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
|
|
19
21
|
|
|
20
22
|
const status = el("p", { className: "sub", textContent: "Building the prompt…" });
|
|
21
23
|
box.append(status);
|
|
22
24
|
|
|
25
|
+
/* A charter brief carries a worked example to model the shape on, matched to the role. The
|
|
26
|
+
choice is on the page because the match is a guess, and "none" is a fair answer. */
|
|
27
|
+
const model = el("select", { hidden: true });
|
|
28
|
+
const modelRow = el("label", { className: "sub", hidden: true }, ["Example to follow: ", model]);
|
|
29
|
+
box.append(modelRow);
|
|
30
|
+
|
|
23
31
|
const actions = el("div", { className: "row" });
|
|
24
32
|
const copy = el("button", { className: "btn primary", textContent: "Copy the prompt" });
|
|
25
33
|
const show = el("button", { className: "btn", textContent: "Show it" });
|
|
@@ -32,8 +40,7 @@ export function paste(opts) {
|
|
|
32
40
|
const answer = el("textarea", {
|
|
33
41
|
className: "pastebox",
|
|
34
42
|
placeholder:
|
|
35
|
-
"Paste
|
|
36
|
-
"Only the part between the sentinels is used.",
|
|
43
|
+
"Paste its whole reply here.",
|
|
37
44
|
});
|
|
38
45
|
answer.rows = 8;
|
|
39
46
|
|
|
@@ -42,16 +49,49 @@ export function paste(opts) {
|
|
|
42
49
|
|
|
43
50
|
let brief = null;
|
|
44
51
|
|
|
45
|
-
|
|
52
|
+
const NAMES = {
|
|
53
|
+
cto: "the CTO example",
|
|
54
|
+
cmo: "the CMO example",
|
|
55
|
+
support: "the support example",
|
|
56
|
+
pm: "the product manager example",
|
|
57
|
+
designer: "the designer example",
|
|
58
|
+
qa: "the QA example",
|
|
59
|
+
devops: "the DevOps example",
|
|
60
|
+
writer: "the writer example",
|
|
61
|
+
analyst: "the analyst example",
|
|
62
|
+
community: "the community example",
|
|
63
|
+
none: "no example",
|
|
64
|
+
};
|
|
65
|
+
model.onchange = () => {
|
|
66
|
+
copy.disabled = true;
|
|
67
|
+
getBrief(opts.kind, opts.staff, model.value, opts.about)
|
|
68
|
+
.then((data) => {
|
|
69
|
+
if (data.error) throw new Error(data.error);
|
|
70
|
+
brief = data;
|
|
71
|
+
if (!preview.hidden) preview.textContent = brief.text;
|
|
72
|
+
})
|
|
73
|
+
.catch((err) => {
|
|
74
|
+
status.className = "err";
|
|
75
|
+
status.textContent = String(err.message || err);
|
|
76
|
+
})
|
|
77
|
+
.finally(() => {
|
|
78
|
+
copy.disabled = false;
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
getBrief(opts.kind, opts.staff, undefined, opts.about)
|
|
46
83
|
.then((data) => {
|
|
47
84
|
if (data.error) throw new Error(data.error);
|
|
48
85
|
brief = data;
|
|
86
|
+
if (data.examples) {
|
|
87
|
+
model.replaceChildren(...data.examples.map((e) => el("option", { value: e, textContent: NAMES[e] ?? e })));
|
|
88
|
+
model.value = data.example ?? "none";
|
|
89
|
+
model.hidden = modelRow.hidden = false;
|
|
90
|
+
}
|
|
49
91
|
status.textContent =
|
|
50
|
-
"
|
|
51
|
-
"so there is nothing to attach and nothing for it to ask you for. It will interview you " +
|
|
52
|
-
"first, then hand back the finished file.";
|
|
92
|
+
"Copy the prompt into Claude or ChatGPT and answer its questions.";
|
|
53
93
|
box.append(
|
|
54
|
-
el("p", { className: "sub", textContent: "Then paste its
|
|
94
|
+
el("p", { className: "sub", textContent: "Then paste its reply here:" }),
|
|
55
95
|
answer,
|
|
56
96
|
el("div", { className: "row" }, [check]),
|
|
57
97
|
result,
|
|
@@ -97,23 +137,47 @@ export function paste(opts) {
|
|
|
97
137
|
result.append(note);
|
|
98
138
|
}
|
|
99
139
|
|
|
140
|
+
// What the AI said around the file: its caveats and "check these" are worth reading first.
|
|
141
|
+
const notes = String(answer.value ?? "")
|
|
142
|
+
.replace(/<<<ROSTER FILE [^>]+>>>[\s\S]*?<<<ROSTER END>>>/g, "")
|
|
143
|
+
.trim();
|
|
144
|
+
|
|
100
145
|
for (const file of data.files ?? []) {
|
|
101
|
-
if (file.unchanged) continue;
|
|
102
146
|
const card = el("div", { className: "pastefile" });
|
|
103
|
-
const
|
|
147
|
+
const name = file.path.split("/").slice(-2).join("/");
|
|
148
|
+
if (file.unchanged) {
|
|
149
|
+
card.append(el("p", { className: "verdict", textContent: "✓ Same as what's already in " + name + ". Nothing to save." }));
|
|
150
|
+
result.append(card);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
104
153
|
card.append(
|
|
105
|
-
el("
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
154
|
+
el("p", {
|
|
155
|
+
className: "verdict" + (file.writable ? " ok" : " bad"),
|
|
156
|
+
textContent: file.writable
|
|
157
|
+
? "✓ Looks good. Saving replaces " + name + " with this:"
|
|
158
|
+
: "✗ " + file.path + " can't be written from here.",
|
|
109
159
|
}),
|
|
110
160
|
);
|
|
111
|
-
|
|
161
|
+
card.append(el("div", { className: "md doc pastepreview", innerHTML: mdlite(file.text) }));
|
|
162
|
+
|
|
163
|
+
if (notes) {
|
|
164
|
+
const said = el("details", { className: "pastenotes", open: true });
|
|
165
|
+
said.append(el("summary", { textContent: "Your AI also said" }), el("div", { className: "md doc", innerHTML: mdlite(notes) }));
|
|
166
|
+
card.append(said);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const diff = el("pre", { className: "diff", hidden: true });
|
|
112
170
|
diff.textContent = unifiedDiff(file.before, file.text, file.path);
|
|
113
|
-
|
|
171
|
+
const stat = diffStat(file.before, file.text);
|
|
172
|
+
const toggle = el("button", { className: "ghbtn", textContent: `Show changes (+${stat.added} −${stat.removed})` });
|
|
173
|
+
toggle.onclick = () => {
|
|
174
|
+
diff.hidden = !diff.hidden;
|
|
175
|
+
toggle.textContent = (diff.hidden ? "Show changes" : "Hide changes") + ` (+${stat.added} −${stat.removed})`;
|
|
176
|
+
};
|
|
114
177
|
|
|
178
|
+
const row = el("div", { className: "row" });
|
|
115
179
|
if (file.writable) {
|
|
116
|
-
const save = el("button", { className: "btn primary", textContent: "Save
|
|
180
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
117
181
|
save.onclick = async () => {
|
|
118
182
|
save.disabled = true;
|
|
119
183
|
save.textContent = "Saving…";
|
|
@@ -124,17 +188,19 @@ export function paste(opts) {
|
|
|
124
188
|
opts.onSaved?.();
|
|
125
189
|
} catch (err) {
|
|
126
190
|
save.disabled = false;
|
|
127
|
-
save.textContent = "Save
|
|
191
|
+
save.textContent = "Save";
|
|
128
192
|
card.append(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
129
193
|
}
|
|
130
194
|
};
|
|
131
|
-
|
|
195
|
+
row.append(save);
|
|
132
196
|
}
|
|
197
|
+
row.append(toggle);
|
|
198
|
+
card.append(row, diff);
|
|
133
199
|
result.append(card);
|
|
134
200
|
}
|
|
135
201
|
|
|
136
202
|
if (!(data.problems ?? []).length && !(data.files ?? []).length) {
|
|
137
|
-
result.append(el("p", { className: "
|
|
203
|
+
result.append(el("p", { className: "verdict bad", textContent: "✗ No file found in that reply." }));
|
|
138
204
|
}
|
|
139
205
|
};
|
|
140
206
|
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* changing a byte of the composed text, and nothing said so before.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { highlighted } from "../mdedit.js";
|
|
12
13
|
import { getFile, post } from "../api.js";
|
|
13
|
-
import { askText } from "../dialog.js";
|
|
14
|
+
import { askText, askYes } from "../dialog.js";
|
|
14
15
|
import { el, esc, grow, kb, toClipboard } from "../dom.js";
|
|
15
16
|
import { icon } from "../icons.js";
|
|
16
17
|
import { mdlite } from "../md.js";
|
|
@@ -351,8 +352,10 @@ export function viewPrompt(m) {
|
|
|
351
352
|
);
|
|
352
353
|
|
|
353
354
|
const ta = el("textarea", { value: text, className: "editor" });
|
|
354
|
-
|
|
355
|
+
const hl = highlighted(ta, "md");
|
|
356
|
+
viewer.append(hl);
|
|
355
357
|
grow(ta);
|
|
358
|
+
hl.repaint();
|
|
356
359
|
ta.addEventListener("input", () => grow(ta));
|
|
357
360
|
|
|
358
361
|
const status = el("span", { className: "meta" });
|
|
@@ -375,8 +378,12 @@ export function viewPrompt(m) {
|
|
|
375
378
|
status.textContent = "nothing changed";
|
|
376
379
|
return;
|
|
377
380
|
}
|
|
378
|
-
|
|
379
|
-
|
|
381
|
+
const yes = await askYes({
|
|
382
|
+
title: "Commit " + layer.path + " to " + layer.repo + " and push?",
|
|
383
|
+
hint: "Picked up by " + reach + ".",
|
|
384
|
+
confirm: "Commit and push",
|
|
385
|
+
});
|
|
386
|
+
if (!yes) return;
|
|
380
387
|
save.disabled = cancel.disabled = true;
|
|
381
388
|
status.textContent = "committing…";
|
|
382
389
|
status.className = "meta";
|