@ersbeth/codelife 0.0.0-stage → 0.11.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/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/agents/evaluate.md +97 -0
- package/agents/ground.md +49 -0
- package/agents/implementer.md +49 -0
- package/agents/judge.md +128 -0
- package/agents/planner.md +45 -0
- package/agents/prerequisites.md +62 -0
- package/agents/reader.md +40 -0
- package/agents/reproduce.md +65 -0
- package/bin/codelife +4 -0
- package/package.json +38 -4
- package/presets/tsconfig.json +14 -0
- package/presets/vite.config.d.mts +3 -0
- package/presets/vite.config.mjs +49 -0
- package/scripts/audit/cadence.mjs +24 -0
- package/scripts/audit/close.mjs +57 -0
- package/scripts/audit/file.mjs +70 -0
- package/scripts/audit/fold.mjs +48 -0
- package/scripts/audit/lib.mjs +93 -0
- package/scripts/audit/prepare.mjs +180 -0
- package/scripts/audit/table.mjs +65 -0
- package/scripts/brainstorm/decided.mjs +6 -0
- package/scripts/brainstorm/lib.mjs +33 -0
- package/scripts/brainstorm/named.mjs +9 -0
- package/scripts/changelog.mjs +100 -0
- package/scripts/check-tag.mjs +19 -0
- package/scripts/checks.mjs +53 -0
- package/scripts/codelife.mjs +42 -0
- package/scripts/commit-msg.mjs +43 -0
- package/scripts/contract.mjs +179 -0
- package/scripts/former-form.mjs +170 -0
- package/scripts/git/branches.mjs +6 -0
- package/scripts/git/commit.mjs +40 -0
- package/scripts/git/diff.mjs +17 -0
- package/scripts/git/diff_lines.mjs +5 -0
- package/scripts/git/held.mjs +53 -0
- package/scripts/git/is_ancestor.mjs +28 -0
- package/scripts/git/lib.mjs +20 -0
- package/scripts/git/log_since.mjs +27 -0
- package/scripts/git/parent.mjs +51 -0
- package/scripts/git/show.mjs +4 -0
- package/scripts/git/status.mjs +25 -0
- package/scripts/git/submodules.mjs +5 -0
- package/scripts/init.mjs +21 -0
- package/scripts/install.mjs +32 -0
- package/scripts/lenses/A11Y.md +91 -0
- package/scripts/lenses/ARCH.md +90 -0
- package/scripts/lenses/BUG.md +52 -0
- package/scripts/lenses/BUILD.md +40 -0
- package/scripts/lenses/COMPAT.md +67 -0
- package/scripts/lenses/CONF.md +49 -0
- package/scripts/lenses/DEPS.md +43 -0
- package/scripts/lenses/DOC.md +73 -0
- package/scripts/lenses/FAIL.md +57 -0
- package/scripts/lenses/GOV.md +56 -0
- package/scripts/lenses/GUARD.md +47 -0
- package/scripts/lenses/INTENT.md +52 -0
- package/scripts/lenses/LEAN.md +49 -0
- package/scripts/lenses/LINES.md +48 -0
- package/scripts/lenses/PERF.md +61 -0
- package/scripts/lenses/PKG.md +58 -0
- package/scripts/lenses/SEC.md +49 -0
- package/scripts/lenses/STANDARDS.md +62 -0
- package/scripts/lenses/TYPE.md +56 -0
- package/scripts/lenses/VERIF.md +81 -0
- package/scripts/link.mjs +40 -0
- package/scripts/methods/cause.md +21 -0
- package/scripts/methods/comments.md +157 -0
- package/scripts/methods/description.md +67 -0
- package/scripts/methods/message.md +24 -0
- package/scripts/methods/red-green.md +28 -0
- package/scripts/methods/refusal.md +22 -0
- package/scripts/mr/open.mjs +219 -0
- package/scripts/node-floor.mjs +19 -0
- package/scripts/onboard/prep.mjs +93 -0
- package/scripts/overview/codelife-line.mjs +33 -0
- package/scripts/overview/next.mjs +76 -0
- package/scripts/overview/overview.mjs +308 -0
- package/scripts/package-manager.mjs +20 -0
- package/scripts/port/item.mjs +94 -0
- package/scripts/port/lib.mjs +194 -0
- package/scripts/port/prep.mjs +33 -0
- package/scripts/port/version.mjs +50 -0
- package/scripts/pre-commit.mjs +43 -0
- package/scripts/pre-push.mjs +53 -0
- package/scripts/prepare-lib.mjs +81 -0
- package/scripts/read-roadmap/lib.mjs +116 -0
- package/scripts/read-roadmap/moved.mjs +70 -0
- package/scripts/read-roadmap/range.mjs +15 -0
- package/scripts/release/cut.mjs +120 -0
- package/scripts/release/entry.mjs +52 -0
- package/scripts/release/lib.mjs +136 -0
- package/scripts/release/prep.mjs +58 -0
- package/scripts/review-mr/prepare.mjs +163 -0
- package/scripts/roadmap/assemble.mjs +269 -0
- package/scripts/roadmap/cut.mjs +88 -0
- package/scripts/roadmap/prepare.mjs +249 -0
- package/scripts/roadmap/roadmap.md +30 -0
- package/scripts/roadmap/write.mjs +53 -0
- package/scripts/search.mjs +29 -0
- package/scripts/setup/contract.mjs +25 -0
- package/scripts/setup/item.mjs +166 -0
- package/scripts/setup/lib.mjs +205 -0
- package/scripts/setup/prep.mjs +106 -0
- package/scripts/spec-form.mjs +444 -0
- package/scripts/spec.mjs +56 -0
- package/scripts/tags.mjs +27 -0
- package/scripts/target/target-app.md +134 -0
- package/scripts/target/target-base.md +127 -0
- package/scripts/target/target-lib.md +143 -0
- package/scripts/target/templates/audit/README.md +21 -0
- package/scripts/target/templates/characterisation/bundle.test.ts +43 -0
- package/scripts/target/templates/characterisation/declarations.test.ts +44 -0
- package/scripts/target/templates/characterisation/entries.ts +50 -0
- package/scripts/target/templates/characterisation/examples.test.ts +18 -0
- package/scripts/target/templates/characterisation/import.test.ts +22 -0
- package/scripts/target/templates/characterisation/surface.test.ts +68 -0
- package/scripts/target/templates/cliff.toml +61 -0
- package/scripts/target/templates/githooks/commit-msg +9 -0
- package/scripts/target/templates/githooks/pre-commit +9 -0
- package/scripts/target/templates/githooks/pre-push +9 -0
- package/scripts/target/templates/playground/index.html +11 -0
- package/scripts/target/templates/playground/main.ts +9 -0
- package/scripts/target/templates/spec/README.md +35 -0
- package/scripts/target/templates/spec/capability.md +36 -0
- package/scripts/target/templates/spec/glossary.md +5 -0
- package/scripts/target/templates/spec/journey.md +29 -0
- package/scripts/target/templates/spec/principles.md +15 -0
- package/scripts/target/templates/spec/users.md +15 -0
- package/scripts/target/templates/tsconfig.json +9 -0
- package/scripts/target/templates/vite.config.ts +4 -0
- package/scripts/text.mjs +6 -0
- package/scripts/tracker/account.mjs +5 -0
- package/scripts/tracker/auth.mjs +9 -0
- package/scripts/tracker/cli.version.mjs +5 -0
- package/scripts/tracker/close.mjs +20 -0
- package/scripts/tracker/create.mjs +47 -0
- package/scripts/tracker/issue.section.mjs +25 -0
- package/scripts/tracker/issue.update.mjs +13 -0
- package/scripts/tracker/issue.view.mjs +16 -0
- package/scripts/tracker/lib.mjs +130 -0
- package/scripts/tracker/list.mjs +14 -0
- package/scripts/tracker/list.whole.mjs +20 -0
- package/scripts/tracker/lot.row.mjs +42 -0
- package/scripts/tracker/mr.checkout_command.mjs +5 -0
- package/scripts/tracker/mr.comments.mjs +50 -0
- package/scripts/tracker/mr.create.mjs +12 -0
- package/scripts/tracker/mr.description.mjs +8 -0
- package/scripts/tracker/mr.fetch_head.mjs +5 -0
- package/scripts/tracker/mr.find_for_branch.mjs +9 -0
- package/scripts/tracker/mr.post.mjs +40 -0
- package/scripts/tracker/mr.update_description.mjs +8 -0
- package/scripts/tracker/mr.verdict.mjs +34 -0
- package/scripts/tracker/mr.view.mjs +20 -0
- package/scripts/tracker/none.mjs +79 -0
- package/scripts/tracker/note.mjs +12 -0
- package/scripts/tracker/reopen.mjs +8 -0
- package/scripts/tracker/search.mjs +8 -0
- package/scripts/tracker/search_id.mjs +18 -0
- package/scripts/tracker/templates/bug.md +35 -0
- package/scripts/tracker/templates/feature.md +40 -0
- package/scripts/tracker/templates/lot.md +46 -0
- package/scripts/tracker/title.join.mjs +6 -0
- package/scripts/tracker/title.leave.mjs +7 -0
- package/scripts/tracker/title.lot_of.mjs +8 -0
- package/scripts/tracker/title.search_id_filter.mjs +17 -0
- package/scripts/tracker/title.sentence_of.mjs +5 -0
- package/scripts/tracker/view.mjs +25 -0
- package/scripts/verbs.mjs +579 -0
- package/scripts/verify/BUILD.mjs +33 -0
- package/scripts/verify/DEPS.mjs +15 -0
- package/scripts/verify/GOV.mjs +34 -0
- package/scripts/verify/GUARD.mjs +34 -0
- package/scripts/verify/LINES.mjs +22 -0
- package/scripts/verify/PKG.mjs +56 -0
- package/scripts/verify/TYPE.mjs +34 -0
- package/scripts/verify/cut.mjs +73 -0
- package/scripts/verify/history.mjs +32 -0
- package/scripts/verify/lib.mjs +129 -0
- package/scripts/verify/report.mjs +104 -0
- package/scripts/verify/timing.mjs +19 -0
- package/scripts/verify.mjs +29 -0
- package/scripts/work/branch.mjs +81 -0
- package/scripts/work/close.mjs +74 -0
- package/scripts/work/member.mjs +73 -0
- package/scripts/work/prepare.mjs +116 -0
- package/scripts/work/reopen.mjs +40 -0
- package/scripts/work-feedback/prepare.mjs +96 -0
- package/skills/audit/SKILL.md +110 -0
- package/skills/audit/templates/index.md +36 -0
- package/skills/audit/templates/lens.md +44 -0
- package/skills/brainstorm/SKILL.md +144 -0
- package/skills/brainstorm/references/exit.md +57 -0
- package/skills/brainstorm/templates/note.md +79 -0
- package/skills/commit/SKILL.md +68 -0
- package/skills/mr/SKILL.md +82 -0
- package/skills/new-issue/SKILL.md +87 -0
- package/skills/onboard/SKILL.md +103 -0
- package/skills/onboard/templates/CLAUDE.md +31 -0
- package/skills/onboard/templates/CONTRIBUTING.md +34 -0
- package/skills/onboard/templates/README.md +30 -0
- package/skills/onboard/templates/SKILL.md +24 -0
- package/skills/onboard/templates/package-CLAUDE.md +15 -0
- package/skills/overview/SKILL.md +59 -0
- package/skills/port-toolchain/SKILL.md +127 -0
- package/skills/release/SKILL.md +78 -0
- package/skills/review-mr/SKILL.md +106 -0
- package/skills/review-mr/templates/pass.md +29 -0
- package/skills/roadmap/SKILL.md +137 -0
- package/skills/setup/SKILL.md +112 -0
- package/skills/work/SKILL.md +139 -0
- package/skills/work-feedback/SKILL.md +113 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The branch's merge request, one command (R-206) — `/mr`, `/work` and `/work-feedback` all push through it.
|
|
2
|
+
// Without `--send` it prints what the confirmation states and sends nothing: the commits leaving, the
|
|
3
|
+
// held choices waiting, the members of the lot still open, how far the branch is behind its target,
|
|
4
|
+
// whether a merge request opens or `!n` is updated — and the facts the description is written from
|
|
5
|
+
// (R-208): the linked issues, the hand commits, the links' base, the run prefix. With `--send` it
|
|
6
|
+
// checks again, refuses a branch behind its target whose merge changes its tree, runs the push tier,
|
|
7
|
+
// pushes with the upstream set — never forced — and creates the merge request or, when the branch
|
|
8
|
+
// has one, replaces the tagged region of its description and nothing outside it. A refusal prints
|
|
9
|
+
// `- stop:` and exits 1, nothing sent.
|
|
10
|
+
// `codelife mr open [<description file>] [--send] [--wrap | --append]`, from the repository's root.
|
|
11
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { packageManager, script } from '../package-manager.mjs';
|
|
16
|
+
import { gist, prep, said, tasks } from '../prepare-lib.mjs';
|
|
17
|
+
import { generatedPaths, git, logged, paths } from '../verify/lib.mjs';
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const send = args.includes('--send'), wrap = args.includes('--wrap'), append = args.includes('--append');
|
|
21
|
+
const files = args.filter((a) => !['--send', '--wrap', '--append'].includes(a));
|
|
22
|
+
if (files.length > 1 || files.some((a) => a.startsWith('-')) || (wrap && append) || ((wrap || append) && !files.length)) {
|
|
23
|
+
console.error('usage: codelife mr open [<description file>] [--send] [--wrap | --append]');
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
const { say, stop, done, op, must, title, kind } = prep('mr open');
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const short = (sha) => sha.slice(0, 8);
|
|
29
|
+
// A marker counts alone on its own line, and the region runs to the last one: a text may quote a marker.
|
|
30
|
+
const REGION = /^<!-- codelife:begin -->[ \t]*$[\s\S]*^<!-- codelife:end -->[ \t]*$/m;
|
|
31
|
+
|
|
32
|
+
if (kind() === 'none') stop('tracker.kind: none — a merge request has no file-based equivalent; nothing opened');
|
|
33
|
+
const branch = git('branch', '--show-current').out;
|
|
34
|
+
if (!branch) stop('HEAD is detached; nothing to open');
|
|
35
|
+
if (/^(develop|main|support\/.+)$/.test(branch)) stop(`on ${branch}, a line of the repository: there is nothing to open`);
|
|
36
|
+
const parent = op('git', 'parent');
|
|
37
|
+
const [target, extra] = parent.stdout.trim().split(' ');
|
|
38
|
+
if (parent.status !== 0 || target === 'tie' || target === 'none') stop(`the branch's parent cannot be derived — ${said(parent)}`);
|
|
39
|
+
if (target === 'finished') stop(`${branch}'s work is merged into ${extra}; nothing to open`);
|
|
40
|
+
const tip = `origin/${target}`;
|
|
41
|
+
say(`target: ${branch} into ${target}`);
|
|
42
|
+
|
|
43
|
+
// The description, when one is given: it carries the region and the template's headings, in order.
|
|
44
|
+
// A refusal says what is expected, read from the template: its markers and its whole list.
|
|
45
|
+
// A first line `# <title>` is the merge request's title, and the description is what follows it.
|
|
46
|
+
let file = files[0], body = '', given = '';
|
|
47
|
+
if (file) {
|
|
48
|
+
try { body = readFileSync(file, 'utf8'); } catch (e) { stop(`the description cannot be read — ${e.message}`); }
|
|
49
|
+
const head = body.match(/^# (.+)\n+/);
|
|
50
|
+
if (head) { given = head[1].trim(); body = body.slice(head[0].length); }
|
|
51
|
+
const heads = (s) => s.split('\n').filter((l) => l.startsWith('## '));
|
|
52
|
+
const wanted = heads(readFileSync(join(here, '../methods/description.md'), 'utf8'));
|
|
53
|
+
const expects = `it must hold <!-- codelife:begin --> … <!-- codelife:end --> around, in this order, the lead and ${wanted.map((h) => `"${h}"`).join(', ')}; nothing sent`;
|
|
54
|
+
if (!REGION.test(body)) stop(`${file} carries no region — ${expects}`);
|
|
55
|
+
const have = heads(body.match(REGION)[0]);
|
|
56
|
+
let at = 0;
|
|
57
|
+
for (const h of wanted) {
|
|
58
|
+
at = have.indexOf(h, at) + 1;
|
|
59
|
+
if (!at) stop(`${file} lacks the heading "${h}", or has it out of order — ${expects}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Behind its target, and what the merge would do.
|
|
64
|
+
const f = git('fetch', '-q', 'origin', target);
|
|
65
|
+
if (f.status) stop(`git fetch origin ${target} failed — ${f.err}`);
|
|
66
|
+
const missing = git('log', '--format=%H%x09%s', `HEAD..${tip}`).out.split('\n').filter(Boolean).map((l) => l.split('\t'));
|
|
67
|
+
const merge = git('merge-tree', '--write-tree', 'HEAD', tip);
|
|
68
|
+
if (merge.status > 1) stop(`git merge-tree failed — ${merge.err}`);
|
|
69
|
+
const conflict = merge.status === 1;
|
|
70
|
+
const unchanged = !conflict && merge.out.split('\n')[0] === git('rev-parse', 'HEAD^{tree}').out;
|
|
71
|
+
say(`behind: ${missing.length} commit${missing.length === 1 ? '' : 's'} behind ${tip}${!missing.length ? ''
|
|
72
|
+
: conflict ? ` — a merge conflicts: stop, nothing merged; \`git merge ${tip}\` brings it in, by hand`
|
|
73
|
+
: unchanged ? ' — a merge leaves its tree unchanged, so it counts as up to date'
|
|
74
|
+
: ` — a merge changes its tree: ask to merge it in, \`git merge ${tip}\`, a red after it the branch merged with its target; on a no, nothing leaves`}`);
|
|
75
|
+
for (const [sha, subject] of missing) say(`missing: ${short(sha)} ${subject}`);
|
|
76
|
+
|
|
77
|
+
// What leaves, and which held choices wait: `git.held`'s range and rules, the one place they are written.
|
|
78
|
+
const held = op('git', 'held', [target]);
|
|
79
|
+
const [first, ...rows] = held.stdout.trim().split('\n');
|
|
80
|
+
if (held.status !== 0 || !first.startsWith('range: ')) stop(`which held choices wait is unknown — ${held.stdout.trim() || said(held)}`);
|
|
81
|
+
const range = first.slice('range: '.length);
|
|
82
|
+
const up = git('rev-parse', '--abbrev-ref', '@{u}');
|
|
83
|
+
const own = up.status === 0 && up.out === `origin/${branch}`;
|
|
84
|
+
const leaving = JSON.parse(must('git', 'log_since', [range]));
|
|
85
|
+
say(`commits leaving: ${leaving.length} — to origin/${branch}`);
|
|
86
|
+
say(`range base: ${range}`);
|
|
87
|
+
const waits = rows.map((r) => r.split('\t')).filter((r) => r[0] === 'waits'), replaced = rows.map((r) => r.split('\t')).filter((r) => r[0] === 'replaced');
|
|
88
|
+
say(`held choices: ${waits.length}`);
|
|
89
|
+
// The later commits that touch a held commit's files: what a refusal names, as they may rest on it.
|
|
90
|
+
const later = (sha) => {
|
|
91
|
+
const files = paths('diff-tree', '--no-commit-id', '--name-only', '-r', sha);
|
|
92
|
+
return files.length ? git('--literal-pathspecs', 'log', '--no-merges', '--format=%h', `${sha}..HEAD`, '--', ...files).out.split('\n').filter(Boolean) : [];
|
|
93
|
+
};
|
|
94
|
+
for (const [, sha, closes, line] of waits) {
|
|
95
|
+
const on = later(sha);
|
|
96
|
+
say(`held: ${sha} — ${line}${closes ? ` — ${closes}` : ''}${on.length ? ` — later commits on its files: ${on.join(', ')}` : ''}`);
|
|
97
|
+
}
|
|
98
|
+
for (const [, sha, by, line] of replaced) say(`replaced: ${sha} — ${line} — replaced by ${by}`);
|
|
99
|
+
|
|
100
|
+
// The members of a lot still open, each with its last STATUS.
|
|
101
|
+
const number = branch.match(/^(?:feature|fix|hotfix|lot)\/(\d+)-/)?.[1];
|
|
102
|
+
const seen = new Map();
|
|
103
|
+
const view = (n) => (seen.get(n) ?? seen.set(n, JSON.parse(must('tracker', 'view', [n]))).get(n));
|
|
104
|
+
// peek — the issue, or null when it cannot be read: a fact of the description never stops the run.
|
|
105
|
+
const peek = (n) => {
|
|
106
|
+
if (!seen.has(n)) { const r = op('tracker', 'view', [n]); seen.set(n, r.status === 0 ? JSON.parse(r.stdout) : null); }
|
|
107
|
+
return seen.get(n);
|
|
108
|
+
};
|
|
109
|
+
const umbrella = /^lot\//.test(branch) && number ? view(number) : null;
|
|
110
|
+
const listed = umbrella ? tasks(umbrella.body).map((t) => t.n) : [];
|
|
111
|
+
// A member is open when its issue is, whatever its line says (R-193).
|
|
112
|
+
const members = listed.map((n) => ({ n, issue: view(n) })).filter((m) => !/^closed$/i.test(m.issue.state));
|
|
113
|
+
say(`open members: ${members.length}`);
|
|
114
|
+
for (const { n, issue } of members) {
|
|
115
|
+
const closing = issue.comments.filter((c) => c.body.startsWith('codelife: closing —')).at(-1);
|
|
116
|
+
say(`open member: #${n} — ${gist(closing?.body ?? '') ?? 'no STATUS'}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The merge request: opens, or is updated.
|
|
120
|
+
const found = must('tracker', 'mr.find_for_branch', [branch]).split('\n').map((l) => l.match(/^(\d+) open$/)).find(Boolean);
|
|
121
|
+
const mr = found?.[1];
|
|
122
|
+
let described = '', mrTitle = '';
|
|
123
|
+
if (mr) {
|
|
124
|
+
say(`merge request: !${mr} updated`);
|
|
125
|
+
if (!file && send) say('description: kept');
|
|
126
|
+
else {
|
|
127
|
+
// The description as it stands, in a file the session reads before it rewrites the region.
|
|
128
|
+
described = must('tracker', 'mr.description', [mr]);
|
|
129
|
+
const now = join(mkdtempSync(join(tmpdir(), 'mr-open-')), 'description.md');
|
|
130
|
+
writeFileSync(now, described);
|
|
131
|
+
if ((wrap || append) && REGION.test(described)) stop(`--wrap and --append answer a description with no region; !${mr}'s carries one, and only it is replaced`);
|
|
132
|
+
say(`${!file ? 'description: kept without a file' : REGION.test(described) ? 'description: carries the region, only it is replaced'
|
|
133
|
+
: 'description: carries no region — ask whether to wrap and rewrite it (--wrap) or to append below it (--append)'} — it reads in ${now}`);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
if (wrap || append) stop('--wrap and --append answer an existing description with no region; this branch has no merge request yet');
|
|
137
|
+
if (!file && send) {
|
|
138
|
+
console.error('usage: codelife mr open <description file> --send — a merge request to create needs its description');
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
// With no file the dry run goes on, so that the facts the description is written from are printed.
|
|
142
|
+
if (!given && !number && file) stop(`${branch} carries no issue number; the title cannot be derived`);
|
|
143
|
+
mrTitle = given || (number ? title('title.sentence_of.mjs', (umbrella ?? view(number)).title) : '');
|
|
144
|
+
if (!mrTitle && file) stop(`the title of #${number} cannot be derived; nothing sent`);
|
|
145
|
+
say(`merge request: opens, visible to colleagues, into ${target}`);
|
|
146
|
+
say(`title: ${mrTitle || `none — ${branch} carries no issue number, so the description opens on \`# <title>\``}`);
|
|
147
|
+
// A branch whose commits close several issues and that no umbrella names: one issue's title is not the whole.
|
|
148
|
+
const closed = new Set(leaving.flatMap((c) => c.closes));
|
|
149
|
+
if (!given && number && !umbrella && closed.size > 1) say(`title: #${number}'s alone, though the commits close ${closed.size} issues — a first line \`# <title>\` in the description gives another`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// What the description is written from (R-208), in the dry run alone: every issue the branch's commits
|
|
153
|
+
// cite, a lot's members and its umbrella, each with its title; the commits no unit's `closing` comment
|
|
154
|
+
// cites, which are the hand commits; the base a link to a document takes; the run prefix; the generated
|
|
155
|
+
// outputs the diff touches, which are never read; the numbers Debt noticed cites, read back (R-209).
|
|
156
|
+
const facts = () => {
|
|
157
|
+
const mine = JSON.parse(must('git', 'log_since', [tip])).reverse();
|
|
158
|
+
const merges = new Set(git('log', '--merges', '--format=%H', `${tip}..HEAD`).out.split('\n').filter(Boolean));
|
|
159
|
+
const nums = (list) => list.map((x) => x.match(/^#(\d+)$/)?.[1]).filter(Boolean);
|
|
160
|
+
const closing = [...new Set(mine.flatMap((c) => nums(c.closes)))];
|
|
161
|
+
const citing = [...new Set([...mine.flatMap((c) => nums(c.refs)), ...listed])].filter((n) => !closing.includes(n) && !(umbrella && n === number));
|
|
162
|
+
const sentence = (n) => { const i = peek(n); return i ? title('title.sentence_of.mjs', i.title) : '(cannot be read)'; };
|
|
163
|
+
for (const n of closing) say(`linked: Closes #${n} — ${sentence(n)}`);
|
|
164
|
+
for (const n of citing) say(`linked: Refs #${n} — ${sentence(n)}`);
|
|
165
|
+
if (umbrella) say(`linked: Lot: #${number} — ${title('title.sentence_of.mjs', umbrella.title)}`);
|
|
166
|
+
const reviewed = (c) => nums(c.closes).some((n) => (peek(n)?.comments ?? []).some((k) => k.body.startsWith('codelife: closing —')
|
|
167
|
+
&& (k.body.match(/\b[0-9a-f]{7,40}\b/g) ?? []).some((h) => c.sha.startsWith(h))));
|
|
168
|
+
const hand = mine.filter((c) => !merges.has(c.sha) && !reviewed(c));
|
|
169
|
+
say(`hand commits: ${hand.length}`);
|
|
170
|
+
for (const c of hand) say(`hand commit: ${short(c.sha)} ${c.subject}`);
|
|
171
|
+
const url = git('remote', 'get-url', 'origin').out;
|
|
172
|
+
const at = url.match(/^[^@/]+@([^:/]+):(.+?)(?:\.git)?$/) || url.match(/^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?$/);
|
|
173
|
+
if (at) {
|
|
174
|
+
const base = `https://${at[1]}/${at[2]}/${kind() === 'gh' ? '' : '-/'}blob`;
|
|
175
|
+
say(`links: ${base}/${target}/<path> — a document this branch adds or rewrites: ${base}/${branch}/<path>`);
|
|
176
|
+
}
|
|
177
|
+
const pm = packageManager('.');
|
|
178
|
+
if (pm) say(`run: ${script(pm, '').slice(0, -1).join(' ')}`);
|
|
179
|
+
const outputs = generatedPaths(), touched = paths('diff', '--name-only', `${tip}...HEAD`)
|
|
180
|
+
.filter((p) => outputs.some((o) => p === o || p.startsWith(`${o}/`)));
|
|
181
|
+
if (touched.length) say(`generated: ${touched.join(', ')} — never read`);
|
|
182
|
+
const debt = (body.match(REGION)?.[0].split(/^## Debt noticed[ \t]*$/m)[1] ?? '').split(/^## /m)[0];
|
|
183
|
+
for (const n of new Set(debt.match(/#\d+\b/g)?.map((x) => x.slice(1)) ?? [])) say(`debt: #${n} — ${sentence(n)}`);
|
|
184
|
+
// An update whose only new commit merges the target in keeps its description (R-212).
|
|
185
|
+
const fresh = own ? git('log', '--first-parent', '--format=%H %P', '@{u}..HEAD').out.split('\n').filter(Boolean) : [];
|
|
186
|
+
if (mr && fresh.length && fresh.every((l) => { const p = l.split(' ')[2]; return p && git('merge-base', '--is-ancestor', p, tip).status === 0; })) {
|
|
187
|
+
say('description: only the target\'s merge is new — keep it: run without a file');
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const dirty = git('status', '--porcelain').out;
|
|
192
|
+
say(`tree: ${dirty ? 'not clean' : 'clean'}`);
|
|
193
|
+
if (!send) { facts(); done(); }
|
|
194
|
+
|
|
195
|
+
// --send: every check again, then the push tier, the push, the merge request.
|
|
196
|
+
if (missing.length && conflict) stop(`a merge of ${tip} conflicts; nothing sent — \`git merge ${tip}\` brings it in, by hand`);
|
|
197
|
+
if (missing.length && !unchanged) stop(`${branch} is behind ${tip} and a merge changes its tree: ${missing.map(([s, m]) => `${short(s)} ${m}`).join('; ')}; nothing sent`);
|
|
198
|
+
if (dirty) stop('the tree is not clean; nothing sent');
|
|
199
|
+
if (mr && file && !REGION.test(described) && !wrap && !append) stop(`!${mr}'s description carries no region; nothing sent until it is decided: --wrap rewrites it whole, --append keeps it and adds the region below`);
|
|
200
|
+
const tier = logged('the push tier', process.execPath, [join(here, '../verify.mjs'), 'push', tip]);
|
|
201
|
+
if (tier.status !== 0) stop(`failed: the push tier — ${tier.out.trim()}`);
|
|
202
|
+
if (git('status', '--porcelain').out) stop('the push tier left changes in the tree, a regenerated output staged; commit them and run again — nothing pushed');
|
|
203
|
+
say('push tier: green');
|
|
204
|
+
if (!own || +git('rev-list', '--count', '@{u}..HEAD').out > 0) {
|
|
205
|
+
const p = git('push', '-u', 'origin', branch);
|
|
206
|
+
if (p.status) stop(`git push failed — ${p.err}`);
|
|
207
|
+
say(`pushed: origin/${branch}`);
|
|
208
|
+
}
|
|
209
|
+
const dir = mkdtempSync(join(tmpdir(), 'mr-open-'));
|
|
210
|
+
if (!mr) {
|
|
211
|
+
writeFileSync(join(dir, 'title'), mrTitle); writeFileSync(join(dir, 'body'), body);
|
|
212
|
+
say(`merge request: ${must('tracker', 'mr.create', [branch, target, join(dir, 'title'), join(dir, 'body')]).trim()}`);
|
|
213
|
+
} else if (file) {
|
|
214
|
+
// Only a description with no region reads the answer: the file replaces it whole, or goes below it.
|
|
215
|
+
writeFileSync(join(dir, 'body'), REGION.test(described) ? described.replace(REGION, () => body.match(REGION)[0]) : append ? `${described.trimEnd()}\n\n${body}` : body);
|
|
216
|
+
const url = must('tracker', 'mr.update_description', [mr, join(dir, 'body')]).trim();
|
|
217
|
+
say(`merge request: !${mr} updated${url ? ` — ${url}` : ''}`);
|
|
218
|
+
} else say(`merge request: !${mr} kept, its description untouched`);
|
|
219
|
+
done();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The check of `node`'s floor, imported first by every script of the package: an ES module's
|
|
2
|
+
// imports run before the importer's body, so a `node` that is too old is named here instead of
|
|
3
|
+
// failing later on an API the script uses. The floor is the `engines.node` of the package.json
|
|
4
|
+
// beside `scripts/`, so it is written once; a package.json that declares none checks nothing.
|
|
5
|
+
// It cannot name a floor for a syntax the old `node` fails to parse in the importer itself, which
|
|
6
|
+
// is why the scripts stay within what the floor parses.
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
let floor
|
|
10
|
+
try {
|
|
11
|
+
const range = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).engines?.node
|
|
12
|
+
floor = /^>=\s*(\d+)\.(\d+)\.(\d+)$/.exec(range ?? '')?.slice(1).map(Number)
|
|
13
|
+
} catch { /* no readable package.json: nothing to check against */ }
|
|
14
|
+
|
|
15
|
+
const have = process.versions.node.split('.').map(Number)
|
|
16
|
+
if (floor && (have[0] - floor[0] || have[1] - floor[1] || have[2] - floor[2]) < 0) {
|
|
17
|
+
console.error(`codelife: node ${process.versions.node} is below ${floor.join('.')}, the lowest version this package runs on`)
|
|
18
|
+
process.exit(2)
|
|
19
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `codelife onboard prep` (K-3 — onboard): everything /onboard reads before it proposes a passage —
|
|
3
|
+
// the kind and the set it makes, each document present or absent and when it last changed against
|
|
4
|
+
// the code, the facts the documents are measured against, and what is known wrong: the open bugs and
|
|
5
|
+
// the notes still pending. Reads only; one `### onboard prep` block; a `- stop:` line and exit 1.
|
|
6
|
+
import '../node-floor.mjs'
|
|
7
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { check, contract, entries } from '../contract.mjs'
|
|
10
|
+
import { unfinished } from '../brainstorm/lib.mjs'
|
|
11
|
+
import { git, json, pkg, read, said, say, sh } from '../setup/lib.mjs'
|
|
12
|
+
import { at, dir } from '../port/lib.mjs'
|
|
13
|
+
|
|
14
|
+
if (process.argv.length > 2) { console.error('usage: codelife onboard prep'); process.exit(2) }
|
|
15
|
+
console.log('### onboard prep')
|
|
16
|
+
const stop = (why) => { say('stop', why); process.exit(1) }
|
|
17
|
+
if (git('rev-parse', '--show-toplevel').status !== 0) stop('not a git repository')
|
|
18
|
+
const c = contract()
|
|
19
|
+
if (!c) stop('no contract — run /setup first')
|
|
20
|
+
const { invalid } = check(c)
|
|
21
|
+
if (invalid.length) stop(`the contract is invalid — ${invalid.join('; ')}: /setup`)
|
|
22
|
+
const kind = c.data.kind
|
|
23
|
+
const pj = json('package.json') ?? {}
|
|
24
|
+
|
|
25
|
+
// The workspace packages: each directory a `packages` glob of package.json or pnpm-workspace.yaml names.
|
|
26
|
+
const globs = [...(Array.isArray(pj.workspaces) ? pj.workspaces : pj.workspaces?.packages ?? []),
|
|
27
|
+
...[...(read('pnpm-workspace.yaml') ?? '').matchAll(/^\s*-\s*['"]?([^'"\s#]+)/gm)].map((m) => m[1])]
|
|
28
|
+
const subdirs = (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(d, e.name)) } catch { return [] } }
|
|
29
|
+
const packages = [...new Set(globs.filter((g) => !g.startsWith('!')).flatMap((g) => (g.endsWith('/*') ? subdirs(g.slice(0, -2)) : [g.replace(/\/$/, '')])))]
|
|
30
|
+
.filter((d) => existsSync(join(d, 'package.json'))).sort()
|
|
31
|
+
|
|
32
|
+
// The set the kind makes: the three root documents, a library's consumer SKILL.md, a package's brief where one exists.
|
|
33
|
+
const set = [['readme', 'README.md'], ['contributing', 'CONTRIBUTING.md'], ['claude', 'CLAUDE.md'],
|
|
34
|
+
...(kind === 'lib' ? [['skill', at('SKILL.md')]] : []),
|
|
35
|
+
...packages.filter((p) => existsSync(join(p, 'CLAUDE.md'))).map((p) => [p.split('/').pop(), join(p, 'CLAUDE.md')])]
|
|
36
|
+
const dirty = git('status', '--porcelain', '--', ...set.map(([, p]) => p)).stdout.trim()
|
|
37
|
+
if (dirty) stop(`a document of the set has changes not committed — a passage's commit would sweep them in: commit or stash them first\n${dirty}`)
|
|
38
|
+
|
|
39
|
+
const date = (...spec) => git('log', '-1', '--format=%cs', '--', ...spec).stdout.trim()
|
|
40
|
+
const code = date('.', ':(exclude,glob)**/*.md')
|
|
41
|
+
say('kind', `${kind} — the set: ${set.map(([n]) => n).join(', ')}${kind === 'lib' ? '' : ` (no consumer SKILL.md under kind ${kind})`}`)
|
|
42
|
+
for (const [name, path] of set) {
|
|
43
|
+
if (!existsSync(path)) { say(`document ${name}`, `${path} — absent`); continue }
|
|
44
|
+
const last = date(path)
|
|
45
|
+
const notes = [last ? `last changed ${last}${code && last < code ? `, before the code (${code})` : ''}` : 'never committed']
|
|
46
|
+
if (name === 'skill') {
|
|
47
|
+
const skill = read(path).match(/^---\n[\s\S]*?^name:[ \t]*(.*)$/m)?.[1].trim().replace(/^(['"])(.*)\1$/, '$2')
|
|
48
|
+
if (!/^[a-z0-9-]{1,64}$/.test(skill ?? '')) notes.push(`its frontmatter name ${skill ? `\`${skill}\` is` : 'is missing,'} not a skill name — lowercase letters, digits and hyphens`)
|
|
49
|
+
const files = json(at('package.json'))?.files
|
|
50
|
+
if (Array.isArray(files) && !files.some((f) => /^(\.\/)?SKILL\.md$/.test(f))) notes.push(`not in ${at('package.json')} files, so it does not ship`)
|
|
51
|
+
}
|
|
52
|
+
say(`document ${name}`, `${path} — present, ${notes.join('; ')}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The facts the documents are measured against, each read from where it lives.
|
|
56
|
+
const p = json(at('package.json')) ?? pj
|
|
57
|
+
say('package', `${p.name ?? '(no name)'}${dir() === '.' ? '' : ` in ${dir()}`}${p.private ? ', private' : ''} — ${p.description ?? 'no description'}`)
|
|
58
|
+
const entry = Object.entries({ main: p.main, module: p.module, types: p.types, exports: p.exports && Object.keys(typeof p.exports === 'string' ? { '.': 0 } : p.exports).join(' '), bin: p.bin && (typeof p.bin === 'string' ? p.bin : Object.keys(p.bin).join(' ')) })
|
|
59
|
+
.filter(([, v]) => v).map(([k, v]) => `${k} ${v}`)
|
|
60
|
+
if (existsSync('index.html')) entry.push(`index.html → ${read('index.html').match(/<script[^>]*src="([^"]+)"/)?.[1] ?? 'no script'}`)
|
|
61
|
+
say('entry', entry.join(' · ') || 'none in the manifest')
|
|
62
|
+
if (packages.length) say('packages', packages.map((d) => `${d}${existsSync(join(d, 'CLAUDE.md')) ? ' (its own CLAUDE.md)' : ''}`).join(' · '))
|
|
63
|
+
const tracked = git('ls-files', '-z').stdout.split('\0').filter(Boolean).filter((f) => !/(^|\/)(dist|node_modules)\//.test(f))
|
|
64
|
+
const byDir = (files) => Object.entries(files.reduce((o, f) => { const d = f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '.'; o[d] = (o[d] ?? 0) + 1; return o }, {}))
|
|
65
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 20).map(([d, n]) => `${d} ${n}`).join(' · ')
|
|
66
|
+
const test = (f) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f) || /(^|\/)(tests?|__tests__)\//.test(f)
|
|
67
|
+
const source = tracked.filter((f) => /\.([cm]?[jt]sx?|vue|svelte)$/.test(f))
|
|
68
|
+
say('modules', byDir(source.filter((f) => !test(f))) || 'no source file')
|
|
69
|
+
say('tests', byDir(source.filter(test)) || 'no test file')
|
|
70
|
+
const hooks = ['.githooks', '.husky'].filter(existsSync).map((h) => `${h}: ${readdirSync(h).filter((f) => !f.startsWith('.') && f !== '_').join(' ')}`)
|
|
71
|
+
say('hooks', hooks.join(' · ') || 'none')
|
|
72
|
+
const version = c.data.release?.version_file
|
|
73
|
+
const sections = [...(read('CHANGELOG.md') ?? '').matchAll(/^## \[([^\]]+)\]/gm)].slice(0, 5).map((m) => m[1])
|
|
74
|
+
say('release', `${version} carries ${json(version)?.version ?? 'a version'}; changelog ${sections.length ? sections.join(' · ') : 'without a section'}`)
|
|
75
|
+
const generated = entries('generated').flatMap((g) => g.path)
|
|
76
|
+
if (generated.length) say('generated', generated.join(' '))
|
|
77
|
+
const licence = readdirSync('.').find((f) => /^(LICEN[CS]E|COPYING)(\.|$)/i.test(f))
|
|
78
|
+
say('licence', `${pj.license ?? p.license ?? 'none in the manifest'}${licence ? `, ${licence}` : ', no licence file'}`)
|
|
79
|
+
|
|
80
|
+
// What is known wrong: the open bugs, by title, then the notes still pending.
|
|
81
|
+
const forge = c.data.tracker?.kind ?? 'none'
|
|
82
|
+
let bugs
|
|
83
|
+
if (forge === 'none') bugs = (existsSync('issues') ? readdirSync('issues').filter((f) => /^\d+-.*\.md$/.test(f)).sort() : []).map((f) => read(join('issues', f)))
|
|
84
|
+
.filter((t) => /^status:[ \t]*open\b/m.test(t) && /^nature:[ \t]*bug\b/m.test(t)).map((t) => `#${t.match(/^id:[ \t]*(\d+)/m)?.[1]}\t${t.match(/^# (.*)$/m)?.[1]}`)
|
|
85
|
+
else {
|
|
86
|
+
const r = sh(process.execPath, [join(pkg, 'scripts/tracker/list.mjs'), '--label', 'bug'])
|
|
87
|
+
bugs = r.status === 0 ? r.stdout.split('\n').filter((l) => l.startsWith('#')).map((l) => l.split('\t').slice(0, 2).join('\t')) : null
|
|
88
|
+
if (!bugs) say('known', `not read — ${said(r)}`)
|
|
89
|
+
}
|
|
90
|
+
if (bugs) { say('known', `${bugs.length} open bug${bugs.length === 1 ? '' : 's'}`); for (const b of bugs) console.log(` ${b.replace('\t', ' ')}`) }
|
|
91
|
+
const notes = existsSync('docs/brainstorm') ? unfinished(['docs/brainstorm']).filter((l) => /^[^:]+:\d+: # /.test(l)) : []
|
|
92
|
+
say('notes', notes.length ? `${notes.length} pending` : 'none pending')
|
|
93
|
+
for (const n of notes) console.log(` ${n.replace(/:\d+: # /, ' — ')}`)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The one line /overview prints about a newer codelife: the newest `@ersbeth/codelife` published
|
|
2
|
+
// past pnpm's release-age window, against the repository's version, naming /setup when it is newer.
|
|
3
|
+
// `node codelife-line.mjs`, from the repository's root. The repository's version is the one its
|
|
4
|
+
// session loads, `node_modules/@ersbeth/codelife/package.json`'s. The window is
|
|
5
|
+
// `minimum-release-age` as the repository's pnpm reads it, in minutes, and pnpm's default of a day
|
|
6
|
+
// when it sets none; a prerelease is no release to move to. A version or a registry that cannot be
|
|
7
|
+
// read is said, its error quoted, and never read as the repository being current.
|
|
8
|
+
import '../node-floor.mjs';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
const sh = (command) => spawnSync(command, { shell: true, encoding: 'utf8' });
|
|
13
|
+
let have;
|
|
14
|
+
try {
|
|
15
|
+
have = JSON.parse(readFileSync('node_modules/@ersbeth/codelife/package.json', 'utf8')).version;
|
|
16
|
+
if (!/^\d+\.\d+\.\d+/.test(have)) throw new Error(`no version in it: '${have}'`);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.log(`codelife: the repository's version could not be read — ${e.message}`);
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
const set = sh('pnpm config get minimum-release-age').stdout.trim();
|
|
22
|
+
const window = set === '' || set === 'undefined' ? 1440 : Number(set);
|
|
23
|
+
const view = sh('pnpm view @ersbeth/codelife time --json');
|
|
24
|
+
if (view.status !== 0) {
|
|
25
|
+
console.log(`codelife: the published version could not be read — ${`${view.stdout} ${view.stderr}`.trim().replace(/\s+/g, ' ')}`);
|
|
26
|
+
} else {
|
|
27
|
+
const cutoff = Date.now() - window * 60000;
|
|
28
|
+
let latest = have;
|
|
29
|
+
for (const [v, at] of Object.entries(JSON.parse(view.stdout)))
|
|
30
|
+
if (/^\d+\.\d+\.\d+$/.test(v) && Date.parse(at) <= cutoff && v.localeCompare(latest, 'en', { numeric: true }) > 0) latest = v;
|
|
31
|
+
console.log(latest === have ? `codelife: ${have}, the latest published past the release-age window`
|
|
32
|
+
: `codelife: ${latest} is published past the release-age window, this repository is on ${have} — /setup`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// /overview's Next (K-6's R-173): the first rule that holds, in a fixed order from blocking to
|
|
2
|
+
// optional, over what `codelife overview` read. Pure, so every case of the table is a test.
|
|
3
|
+
//
|
|
4
|
+
// Each source of the state is `{ ok: <value> }` or `{ unreadable: '<source> — <error>' }`. A rule
|
|
5
|
+
// holds on what was read; at the first rule that does not hold and whose sources include an
|
|
6
|
+
// unreadable one, the table stops: no lower rule is reached on a failed read, the last least of all.
|
|
7
|
+
//
|
|
8
|
+
// The state: contract { status, lines } · base [<item to do>] · connected bool (null under `none`) ·
|
|
9
|
+
// plans, stops [{ n, to, why }] (`to` the number /work takes; a stop waiting for a case has
|
|
10
|
+
// `waits`) · held { to, why } | null · campaign <folder> | null · untagged <version> | null ·
|
|
11
|
+
// notes [<slug>] · due <why> | null · mrs [{ n, own, reviewed }] · ahead <why> | null ·
|
|
12
|
+
// lots { start: [<umbrella>], lacked: [<umbrella>] } · orphans [{ n, left }].
|
|
13
|
+
import '../node-floor.mjs';
|
|
14
|
+
|
|
15
|
+
// The rules, in order. `reads` names the state's sources a rule's condition reads; `skills` the
|
|
16
|
+
// commands it can name, which tests/overview.test.mjs holds to the menu.
|
|
17
|
+
export const RULES = [
|
|
18
|
+
{ row: 1, reads: ['contract', 'base'], skills: ['/setup'], test: ({ contract, base }) =>
|
|
19
|
+
contract && contract.status !== 'ok' ? `the contract is ${contract.status}: ${contract.lines[0]}`
|
|
20
|
+
: base?.length ? `a base item is left to do: ${base[0]}` : null, next: () => '/setup' },
|
|
21
|
+
{ row: 2, reads: ['connected'], skills: ['/setup'], test: ({ connected }) =>
|
|
22
|
+
connected === false ? 'this machine is not connected to the forge' : null, next: () => '/setup' },
|
|
23
|
+
{ row: 3, reads: ['plans'], skills: ['/work'], test: ({ plans }) =>
|
|
24
|
+
plans?.length ? [plans[0], `#${plans[0].n} carries a plan with no closing comment`] : null,
|
|
25
|
+
next: ([p]) => `/work ${p.to}` },
|
|
26
|
+
{ row: 3, reads: ['stops'], skills: ['/work', '/roadmap'], test: ({ stops }) =>
|
|
27
|
+
stops?.length ? [stops[0], `#${stops[0].n}: ${stops[0].why}`] : null,
|
|
28
|
+
next: ([s]) => (s.waits ? '/roadmap' : `/work ${s.to}`) },
|
|
29
|
+
{ row: 3, reads: ['held'], skills: ['/work'], test: ({ held }) => (held ? [held, held.why] : null),
|
|
30
|
+
next: ([h]) => `/work ${h.to}` },
|
|
31
|
+
{ row: 3, reads: ['campaign'], skills: ['/audit'], test: ({ campaign }) =>
|
|
32
|
+
campaign ? `the campaign ${campaign} is in progress` : null, next: () => '/audit' },
|
|
33
|
+
{ row: 3, reads: ['untagged'], skills: ['/release'], test: ({ untagged }) =>
|
|
34
|
+
untagged ? `the version ${untagged} carries no tag` : null, next: () => '/release' },
|
|
35
|
+
{ row: 3, reads: ['notes'], skills: ['/brainstorm'], test: ({ notes }) =>
|
|
36
|
+
notes?.length ? [notes[0], `docs/brainstorm/ holds ${notes.length === 1 ? 'a note' : `${notes.length} notes`}`] : null,
|
|
37
|
+
next: ([slug]) => `/brainstorm ${slug}` },
|
|
38
|
+
{ row: 4, reads: ['due'], skills: ['/audit'], test: ({ due }) => due ?? null, next: () => '/audit' },
|
|
39
|
+
{ row: 5, reads: ['mrs'], skills: ['/review-mr'], test: ({ mrs }) => {
|
|
40
|
+
const m = mrs?.find((x) => !x.own && !x.reviewed);
|
|
41
|
+
return m ? [m, `!${m.n}, another account's, has no review`] : null;
|
|
42
|
+
}, next: ([m]) => `/review-mr ${m.n}` },
|
|
43
|
+
{ row: 6, reads: ['ahead'], skills: ['/roadmap'], test: ({ ahead }) => ahead ?? null, next: () => '/roadmap' },
|
|
44
|
+
{ row: 7, reads: ['lots'], skills: ['/work'], test: ({ lots }) => {
|
|
45
|
+
if (!lots) return null;
|
|
46
|
+
const [first, ...rest] = lots.start.length ? lots.start : lots.lacked;
|
|
47
|
+
if (!first) return null;
|
|
48
|
+
const beside = lots.start.length ? rest : [];
|
|
49
|
+
return [first, `lot #${first} can start${beside.length ? `, and beside it ${beside.map((n) => `#${n}`).join(', ')}` : ''}${lots.start.length ? '' : ' — no lot of the order can, and the order lacks it'}`];
|
|
50
|
+
}, next: ([n]) => `/work ${n}` },
|
|
51
|
+
{ row: 8, reads: ['orphans'], skills: ['/roadmap', '/work'], test: ({ orphans }) => {
|
|
52
|
+
if (!orphans?.length) return null;
|
|
53
|
+
const fresh = orphans.find((o) => !o.left);
|
|
54
|
+
if (fresh) return [null, `#${fresh.n} is an orphan the roadmap does not leave alone`];
|
|
55
|
+
const low = [...orphans].sort((a, b) => a.n - b.n)[0];
|
|
56
|
+
return [low, `#${low.n}, an orphan the last /roadmap left alone, is work of its own`];
|
|
57
|
+
}, next: ([o]) => (o ? `/work ${o.n}` : '/roadmap') },
|
|
58
|
+
{ row: 9, reads: [], skills: ['/brainstorm'], test: () => 'nothing is due, half-done or orphan: the next subject is yours', next: () => '/brainstorm' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// next(state) — { row, next, why }: the first rule that holds, or the stop at a failed read.
|
|
62
|
+
export const next = (state) => {
|
|
63
|
+
for (const rule of RULES) {
|
|
64
|
+
const values = {}, failed = [];
|
|
65
|
+
for (const k of rule.reads) {
|
|
66
|
+
if (state[k] && 'unreadable' in state[k]) failed.push(state[k].unreadable);
|
|
67
|
+
else values[k] = state[k]?.ok;
|
|
68
|
+
}
|
|
69
|
+
const hit = rule.test(values);
|
|
70
|
+
if (hit) {
|
|
71
|
+
const [arg, why] = Array.isArray(hit) ? hit : [null, hit];
|
|
72
|
+
return { row: rule.row, next: rule.next([arg]), why };
|
|
73
|
+
}
|
|
74
|
+
if (failed.length) return { row: rule.row, next: '/overview', why: `cannot conclude past row ${rule.row}: ${failed[0]} — run /overview again once it answers` };
|
|
75
|
+
}
|
|
76
|
+
};
|