create-pmos 0.53.0 → 0.53.2
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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +107 -65
- package/workspace/bin/pmos.js +4 -2
- package/workspace/first-paint-audit.js +6 -2
- package/workspace/fixtures/initiatives-api/list.json +13 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
- package/workspace/fixtures/report-api/draft.json +105 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
- package/workspace/guide/command-palette.md +1 -1
- package/workspace/guide/index.md +3 -1
- package/workspace/guide/initiatives-roadmap.md +1 -0
- package/workspace/guide/initiatives-screen.md +1 -0
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +7 -2
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/guide/report-slides.md +28 -0
- package/workspace/guide/reports.md +2 -0
- package/workspace/lib/auto-sync.js +83 -0
- package/workspace/lib/board-fold.js +2 -2
- package/workspace/lib/breakdown-export.js +1 -6
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/initiative-rows.js +7 -3
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/plan-tree.js +15 -2
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +2 -2
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +184 -12
- package/workspace/lib/report-draft.js +34 -6
- package/workspace/lib/report-slides-data.js +141 -0
- package/workspace/lib/routes-initiatives.js +35 -2
- package/workspace/lib/routes-reports.js +3 -3
- package/workspace/lib/routes-timeline.js +1 -1
- package/workspace/lib/sync.js +61 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +11 -0
- package/workspace/public/pm/app.js +55 -9
- package/workspace/public/pm/components.css +865 -1
- package/workspace/public/pm/dest-reports.js +8 -2
- package/workspace/public/pm/dest-today.js +8 -2
- package/workspace/public/pm/dock.js +73 -3
- package/workspace/public/pm/frame-copy.js +5 -0
- package/workspace/public/pm/initiative-list.js +18 -1
- package/workspace/public/pm/initiatives-copy.js +4 -0
- package/workspace/public/pm/panel-tabs.js +14 -12
- package/workspace/public/pm/print.css +11 -0
- package/workspace/public/pm/report-slides.js +438 -0
- package/workspace/public/pm/report-view.js +8 -2
- package/workspace/public/pm/reports-copy.js +3 -0
- package/workspace/public/pm/roadmap.js +23 -0
- package/workspace/public/pm/slides-copy.js +52 -0
- package/workspace/public/pm/today-copy.js +29 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/public/pm/tokens.css +13 -0
- package/workspace/public/pm/topbar.js +5 -2
- package/workspace/replaced-tests.json +36 -1
- package/workspace/server.js +165 -15
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +1 -0
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/run6.json +9 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -0
- package/workspace/test-lists/workspace-public-host.json +5 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* launcher-cli.js — `pmos launcher on|off|status`: start the workspace without a terminal (initiative workspace-launcher).
|
|
3
|
+
*
|
|
4
|
+
* pmos launcher on [<path>] [--no-login] write a PMOS app (and, unless --no-login, a login item) for one repository
|
|
5
|
+
* pmos launcher off remove what `on` wrote, and nothing else
|
|
6
|
+
* pmos launcher status say what is installed and whether it still points at a real node and repo
|
|
7
|
+
*
|
|
8
|
+
* The app and the login item are two more callers of `pmos up <repo>`; nothing here starts a server itself. The app
|
|
9
|
+
* opens the browser. The login item passes --no-open, so a login starts the server and opens nothing.
|
|
10
|
+
*
|
|
11
|
+
* macOS ~/Applications/PMOS.app Spotlight, Launchpad, drag it to the Dock
|
|
12
|
+
* ~/Library/LaunchAgents/com.pmos.workspace.plist runs at login (RunAtLoad), once
|
|
13
|
+
* Linux $XDG_DATA_HOME/applications/pmos.desktop the app menu
|
|
14
|
+
* $XDG_CONFIG_HOME/autostart/pmos-workspace.desktop
|
|
15
|
+
*
|
|
16
|
+
* A file started by the desktop has no shell profile, so the PATH of the terminal that ran `on` is written into the
|
|
17
|
+
* script; that is how `claude`, `git` and `tmux` resolve. It never starts remote (tailnet) mode and writes no token.
|
|
18
|
+
* `off` deletes a file only when it carries MARK, so a file of the same name that the PM wrote is left alone.
|
|
19
|
+
* Native Windows is refused: the workspace needs WSL there for a real PTY (doctor.js says the same).
|
|
20
|
+
*
|
|
21
|
+
* plan() is pure: every input is injected, so each platform's files are assertable from any machine.
|
|
22
|
+
*/
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { spawnSync } = require('child_process');
|
|
27
|
+
const stateRoot = require('./state-root');
|
|
28
|
+
|
|
29
|
+
const COMMANDS = { launcher: ['on', 'off', 'status'] };
|
|
30
|
+
const MARK = 'pmos-launcher: written by `pmos launcher on`';
|
|
31
|
+
const LABEL = 'com.pmos.workspace';
|
|
32
|
+
const USAGE = [
|
|
33
|
+
'usage: pmos launcher on|off|status — start the workspace without a terminal',
|
|
34
|
+
' pmos launcher on [<path>] [--no-login] a PMOS app for this repository, and a login item that starts the server',
|
|
35
|
+
' pmos launcher off remove the app and the login item',
|
|
36
|
+
' pmos launcher status what is installed, and for which repository',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// POSIX shell single quotes: nothing inside is special except the quote itself.
|
|
40
|
+
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
41
|
+
// XML text and attribute values in a plist.
|
|
42
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
43
|
+
// A .desktop Exec argument: double-quoted, with the four characters the spec reserves escaped.
|
|
44
|
+
const deq = (s) => `"${String(s).replace(/[\\"`$]/g, (c) => '\\' + c)}"`;
|
|
45
|
+
|
|
46
|
+
function script({ node, bin, repo, pathEnv, log }) {
|
|
47
|
+
return [
|
|
48
|
+
'#!/bin/sh',
|
|
49
|
+
`# ${MARK} (workspace/lib/launcher-cli.js). \`pmos launcher off\` removes it.`,
|
|
50
|
+
'# It runs `pmos up` on one repository with the PATH the terminal had at setup. Arguments pass through (--no-open).',
|
|
51
|
+
`PATH=${shq(pathEnv)}; export PATH`,
|
|
52
|
+
`exec ${shq(node)} ${shq(bin)} up ${shq(repo)} "$@" >>${shq(log)} 2>&1`,
|
|
53
|
+
'',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function plist(dict) {
|
|
58
|
+
const v = (x) => Array.isArray(x) ? `<array>${x.map((s) => `<string>${xml(s)}</string>`).join('')}</array>`
|
|
59
|
+
: typeof x === 'boolean' ? (x ? '<true/>' : '<false/>') : `<string>${xml(x)}</string>`;
|
|
60
|
+
return [
|
|
61
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
62
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
63
|
+
`<!-- ${MARK} (workspace/lib/launcher-cli.js). \`pmos launcher off\` removes it. -->`,
|
|
64
|
+
'<plist version="1.0">',
|
|
65
|
+
'<dict>',
|
|
66
|
+
...Object.entries(dict).map(([k, x]) => ` <key>${xml(k)}</key>${v(x)}`),
|
|
67
|
+
'</dict>',
|
|
68
|
+
'</plist>',
|
|
69
|
+
'',
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function desktop(entries) {
|
|
74
|
+
return [`# ${MARK} (workspace/lib/launcher-cli.js). \`pmos launcher off\` removes it.`, '[Desktop Entry]',
|
|
75
|
+
...Object.entries(entries).map(([k, x]) => `${k}=${x}`), ''].join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The files `on` writes, for one platform. Pure.
|
|
80
|
+
* → { files: [{ role: 'app'|'login', path, content, mode }], app, login } or { refused: '<reason>' }
|
|
81
|
+
* `login: false` leaves the login item out.
|
|
82
|
+
*/
|
|
83
|
+
function plan({ platform, home, env = {}, node, bin, repo, login = true }) {
|
|
84
|
+
if (platform === 'win32') return { refused: 'native Windows needs WSL for a real PTY. Run `pmos launcher on` inside WSL.' };
|
|
85
|
+
if (platform !== 'darwin' && platform !== 'linux') return { refused: `no launcher is made for ${platform}. Start the workspace with \`pmos\`.` };
|
|
86
|
+
const log = path.join(stateRoot.defaultRoot({ env, platform, home }), 'launcher.log');
|
|
87
|
+
const body = script({ node, bin, repo, pathEnv: env.PATH || '/usr/bin:/bin', log });
|
|
88
|
+
const files = [];
|
|
89
|
+
if (platform === 'darwin') {
|
|
90
|
+
const appDir = path.join(home, 'Applications', 'PMOS.app');
|
|
91
|
+
const exe = path.join(appDir, 'Contents', 'MacOS', 'pmos-launch');
|
|
92
|
+
files.push({ role: 'app', path: exe, content: body, mode: 0o755 });
|
|
93
|
+
files.push({ role: 'app', path: path.join(appDir, 'Contents', 'Info.plist'), mode: 0o644, content: plist({
|
|
94
|
+
CFBundleName: 'PMOS', CFBundleDisplayName: 'PMOS', CFBundleIdentifier: 'com.pmos.launcher',
|
|
95
|
+
CFBundleExecutable: 'pmos-launch', CFBundlePackageType: 'APPL', CFBundleVersion: '1',
|
|
96
|
+
// No Dock icon: the script hands off to the browser and exits within seconds.
|
|
97
|
+
LSUIElement: true,
|
|
98
|
+
}) });
|
|
99
|
+
const agent = path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
100
|
+
if (login) files.push({ role: 'login', path: agent, mode: 0o644, content: plist({
|
|
101
|
+
Label: LABEL, ProgramArguments: ['/bin/sh', exe, '--no-open'], RunAtLoad: true,
|
|
102
|
+
// `pmos up` detaches the server and exits; launchd must not reap the group it leaves behind.
|
|
103
|
+
AbandonProcessGroup: true,
|
|
104
|
+
}) });
|
|
105
|
+
return { files, app: appDir, login: login ? agent : null, removable: [appDir, agent] };
|
|
106
|
+
}
|
|
107
|
+
const data = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, '.local', 'share');
|
|
108
|
+
const conf = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.join(home, '.config');
|
|
109
|
+
const exe = path.join(data, 'pmos', 'pmos-launch.sh');
|
|
110
|
+
const app = path.join(data, 'applications', 'pmos.desktop');
|
|
111
|
+
const auto = path.join(conf, 'autostart', 'pmos-workspace.desktop');
|
|
112
|
+
files.push({ role: 'app', path: exe, content: body, mode: 0o755 });
|
|
113
|
+
files.push({ role: 'app', path: app, mode: 0o644, content: desktop({
|
|
114
|
+
Type: 'Application', Name: 'PMOS', Comment: 'Open the PMOS workspace', Exec: `/bin/sh ${deq(exe)}`,
|
|
115
|
+
Terminal: 'false', Categories: 'Development;' }) });
|
|
116
|
+
if (login) files.push({ role: 'login', path: auto, mode: 0o644, content: desktop({
|
|
117
|
+
Type: 'Application', Name: 'PMOS workspace', Comment: 'Start the PMOS workspace server at login',
|
|
118
|
+
Exec: `/bin/sh ${deq(exe)} --no-open`, Terminal: 'false', NoDisplay: 'true', 'X-GNOME-Autostart-enabled': 'true' }) });
|
|
119
|
+
return { files, app, login: login ? auto : null, removable: [exe, app, auto] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const marked = (f) => { try { return fs.readFileSync(f, 'utf8').includes(MARK); } catch { return false; } };
|
|
123
|
+
// The file under a removable path that carries the mark: the bundle's script for a .app, else the path itself.
|
|
124
|
+
const markFile = (p) => p.endsWith('.app') ? path.join(p, 'Contents', 'MacOS', 'pmos-launch') : p;
|
|
125
|
+
|
|
126
|
+
function repoRootOf(dir) {
|
|
127
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' });
|
|
128
|
+
return r.status === 0 ? String(r.stdout).trim() : dir;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// What the installed script points at: the repository and whether node and pmos.js still exist.
|
|
132
|
+
function readTarget(exe) {
|
|
133
|
+
let src = '';
|
|
134
|
+
try { src = fs.readFileSync(exe, 'utf8'); } catch { return null; }
|
|
135
|
+
const m = src.match(/^exec '((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' up '((?:[^']|'\\'')*)'/m);
|
|
136
|
+
if (!m) return null;
|
|
137
|
+
const un = (s) => s.replace(/'\\''/g, "'");
|
|
138
|
+
return { node: un(m[1]), bin: un(m[2]), repo: un(m[3]) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function run(argv, { say = console.log, platform = process.platform, home = os.homedir(), env = process.env,
|
|
142
|
+
node = process.execPath, bin = path.join(__dirname, '..', 'bin', 'pmos.js'), cwd = process.cwd() } = {}) {
|
|
143
|
+
const pos = argv.slice(1).filter((a) => !a.startsWith('--'));
|
|
144
|
+
const flags = new Set(argv.slice(1).filter((a) => a.startsWith('--')).map((a) => a.slice(2)));
|
|
145
|
+
const unknown = [...flags].filter((f) => f !== 'no-login' && f !== 'help');
|
|
146
|
+
if (unknown.length) { say(`✗ unknown option --${unknown[0]}`); for (const l of USAGE) say(l); return 2; }
|
|
147
|
+
const sub = pos[0];
|
|
148
|
+
if (!sub || flags.has('help') || sub === 'help') { for (const l of USAGE) say(l); return sub ? 0 : 2; }
|
|
149
|
+
if (!COMMANDS.launcher.includes(sub)) { say(`✗ pmos launcher ${sub} is not a command`); for (const l of USAGE) say(l); return 2; }
|
|
150
|
+
|
|
151
|
+
if (sub === 'on') {
|
|
152
|
+
const dir = pos[1] ? path.resolve(cwd, pos[1]) : cwd;
|
|
153
|
+
if (!fs.existsSync(dir)) { say(`✗ ${dir} does not exist`); return 1; }
|
|
154
|
+
const repo = repoRootOf(dir);
|
|
155
|
+
const p = plan({ platform, home, env, node, bin, repo, login: !flags.has('no-login') });
|
|
156
|
+
if (p.refused) { say(`✗ ${p.refused}`); return 1; }
|
|
157
|
+
// Never write over a file of the same name that the launcher did not write.
|
|
158
|
+
const foreign = p.files.filter((f) => fs.existsSync(f.path) && !marked(f.path));
|
|
159
|
+
if (foreign.length) { say(`✗ not written: ${foreign.map((f) => f.path).join(', ')} exists and was not written by pmos launcher`); return 1; }
|
|
160
|
+
for (const f of p.files) {
|
|
161
|
+
fs.mkdirSync(path.dirname(f.path), { recursive: true });
|
|
162
|
+
fs.writeFileSync(f.path, f.content, { mode: f.mode });
|
|
163
|
+
fs.chmodSync(f.path, f.mode);
|
|
164
|
+
}
|
|
165
|
+
say(`✓ PMOS app for ${repo}`);
|
|
166
|
+
say(` ${p.app}`);
|
|
167
|
+
say(platform === 'darwin' ? ' Open it from Spotlight or Launchpad, or drag it to the Dock.' : ' Open it from the app menu.');
|
|
168
|
+
if (p.login) {
|
|
169
|
+
say('✓ login item: the workspace server starts when you log in, without a browser window');
|
|
170
|
+
say(` ${p.login}`);
|
|
171
|
+
} else say(' no login item (--no-login): the server starts when you open the app');
|
|
172
|
+
say(' The app opens the browser on the running workspace and starts the server when none runs.');
|
|
173
|
+
say(' If you move or upgrade node, run `pmos launcher on` again.');
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const p = plan({ platform, home, env, node, bin, repo: cwd });
|
|
178
|
+
if (p.refused) { say(`✗ ${p.refused}`); return 1; }
|
|
179
|
+
|
|
180
|
+
if (sub === 'off') {
|
|
181
|
+
let removed = 0;
|
|
182
|
+
for (const r of p.removable) {
|
|
183
|
+
if (!fs.existsSync(r)) continue;
|
|
184
|
+
if (!marked(markFile(r))) { say(`· left ${r}: it was not written by pmos launcher`); continue; }
|
|
185
|
+
fs.rmSync(r, { recursive: true, force: true });
|
|
186
|
+
say(`✓ removed ${r}`);
|
|
187
|
+
removed++;
|
|
188
|
+
}
|
|
189
|
+
if (!removed) say('○ nothing to remove: no launcher is installed');
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// status
|
|
194
|
+
const exe = p.files.find((f) => f.role === 'app' && f.mode === 0o755).path;
|
|
195
|
+
const t = marked(exe) ? readTarget(exe) : null;
|
|
196
|
+
const loginPath = p.removable.find((r) => r.endsWith('.plist') || r.endsWith('pmos-workspace.desktop'));
|
|
197
|
+
const login = fs.existsSync(loginPath) && marked(loginPath);
|
|
198
|
+
if (!t) { say('○ no PMOS app is installed (pmos launcher on)'); if (login) say(`· a login item is installed without the app: ${loginPath}`); return 1; }
|
|
199
|
+
say(`✓ PMOS app — ${p.app}`);
|
|
200
|
+
say(` repository: ${t.repo}${fs.existsSync(t.repo) ? '' : ' ✗ not found'}`);
|
|
201
|
+
say(` node: ${t.node}${fs.existsSync(t.node) ? '' : ' ✗ not found — run `pmos launcher on` again'}`);
|
|
202
|
+
say(` pmos: ${t.bin}${fs.existsSync(t.bin) ? '' : ' ✗ not found — run `pmos launcher on` again'}`);
|
|
203
|
+
say(login ? `✓ login item — ${loginPath}` : '○ no login item: the server starts when you open the app');
|
|
204
|
+
return [t.repo, t.node, t.bin].every((x) => fs.existsSync(x)) ? 0 : 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { run, plan, readTarget, COMMANDS, USAGE, MARK };
|
|
@@ -110,7 +110,20 @@ function profileAndLane(root, initiative) {
|
|
|
110
110
|
// An unknown or missing lane is the profile's strictest, exactly as workflow-state.js computes `effLane`.
|
|
111
111
|
const fallback = profile === 'pm-artifacts' ? 'prd' : 'feature';
|
|
112
112
|
const lane = declared && defs[declared] ? declared : fallback;
|
|
113
|
-
return { profile, lane, defs, record: file };
|
|
113
|
+
return { profile, lane, defs, record: file, group: viewGroup(defs, declared) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether an initiative is a work card or a document in the planning views (D86 point 4), from its DECLARED lane.
|
|
118
|
+
*
|
|
119
|
+
* Only a record that declares a document lane is a document. A missing or unknown lane is work: the fallback above
|
|
120
|
+
* picks the strictest ladder for the gate, and in a pm-artifacts repository that fallback is `prd`, a document lane.
|
|
121
|
+
* Grouping by the fallback hid every record written before lanes were declared (0.53.0: most of a kit repository's
|
|
122
|
+
* initiatives were missing from Initiatives, Board and Reports).
|
|
123
|
+
*/
|
|
124
|
+
function viewGroup(defs, declared) {
|
|
125
|
+
const d = String(declared || '').trim();
|
|
126
|
+
return d && defs && defs[d] && defs[d].group ? defs[d].group : 'work';
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
const cap = (s) => (s && /^[a-z]/.test(s) ? s[0].toUpperCase() + s.slice(1) : s);
|
|
@@ -363,5 +376,5 @@ function layout({ root, initiative, fold, ladder, now }) {
|
|
|
363
376
|
|
|
364
377
|
module.exports = {
|
|
365
378
|
STAGE_GROUPS, STEP_NOUN, OWED_ACTOR, OPEN, DONE_WINDOW_MS, INIT_RE, NOT_MERGED_LABEL, withFactory,
|
|
366
|
-
mainTree, recordPath, profileAndLane, stagesFor, stageIdsFor, resolveStage, owedFor, nounFor, byLabel, progressLabel, layout, historyText,
|
|
379
|
+
mainTree, recordPath, profileAndLane, viewGroup, stagesFor, stageIdsFor, resolveStage, owedFor, nounFor, byLabel, progressLabel, layout, historyText,
|
|
367
380
|
};
|
|
@@ -34,6 +34,9 @@ const VERBS = Object.freeze({
|
|
|
34
34
|
help: 'pmos verify-work — mark work items integrated from the default branch (local refs only)' }),
|
|
35
35
|
report: Object.freeze({ module: 'report-cli.js', run: 5, guide: 'reports.md',
|
|
36
36
|
help: 'pmos report draft|issue|list … — draft and issue reports (pmos report help)' }),
|
|
37
|
+
// Not a pm-os-absorb verb (run: null): the workspace-launcher initiative added it through the same table.
|
|
38
|
+
launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
|
|
39
|
+
help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
|
|
37
40
|
});
|
|
38
41
|
|
|
39
42
|
const has = (verb) => typeof verb === 'string' && Object.prototype.hasOwnProperty.call(VERBS, verb);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* pmos proactive brief [show]
|
|
14
14
|
* pmos proactive brief submit --file PATH
|
|
15
15
|
* pmos proactive brief schedule [--repo PATH]
|
|
16
|
+
* pmos proactive goals [list] | add TEXT [--initiative ID] | done N | drop N | reopen N | remove N | plan [--repo PATH]
|
|
16
17
|
* pmos proactive prepare plan --initiative ID [--repo PATH]
|
|
17
18
|
* pmos proactive prepare queue --initiative ID --item ITEM --sha HASH [--repo PATH]
|
|
18
19
|
* pmos proactive prepare status | cancel ID | run
|
|
@@ -23,8 +24,8 @@ const path = require('path');
|
|
|
23
24
|
const { execFileSync } = require('child_process');
|
|
24
25
|
const { parseArgs } = require('./schedule-cli');
|
|
25
26
|
|
|
26
|
-
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare', 'learn'] };
|
|
27
|
-
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
27
|
+
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'goals', 'prepare', 'learn'] };
|
|
28
|
+
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence'];
|
|
28
29
|
const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
|
|
29
30
|
|
|
30
31
|
async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
@@ -189,6 +190,61 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
|
189
190
|
for (const l of r.lessons.filter((x) => x.status === 'proposed' || x.status === 'approved')) say(`${l.id} ${l.status} ${l.text}`);
|
|
190
191
|
return 0;
|
|
191
192
|
}
|
|
193
|
+
if (verb === 'goals') {
|
|
194
|
+
const sub = pos[0] || 'list';
|
|
195
|
+
const usage = 'usage: pmos proactive goals [list] | add TEXT [--initiative ID] | done N | drop N | reopen N | remove N | plan [--repo PATH]';
|
|
196
|
+
const wsOfRepo = async () => {
|
|
197
|
+
let root; try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: flags.repo ? path.resolve(cwd, flags.repo) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
198
|
+
const wl = await call('GET', '/api/workspaces');
|
|
199
|
+
const hit = ((wl && wl.workspaces) || []).find((w) => path.resolve(w.root) === path.resolve(root));
|
|
200
|
+
return hit ? hit.id : null;
|
|
201
|
+
};
|
|
202
|
+
const get = async () => { const ws = await wsOfRepo(); return call('GET', '/api/proactive/goals' + (ws ? '?ws=' + encodeURIComponent(ws) : '')); };
|
|
203
|
+
if (sub === 'add') {
|
|
204
|
+
const text = pos.slice(1).join(' ');
|
|
205
|
+
if (!text.trim()) return fail(usage);
|
|
206
|
+
const r = await call('POST', '/api/proactive/goals', { action: 'add', text, ...(flags.initiative ? { initiative_id: flags.initiative } : {}) });
|
|
207
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
208
|
+
say(`added goal ${r.day.goals.length}: ${r.goal.text}${r.goal.initiative_id ? ' · ' + r.goal.initiative_id : ''}`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
if (['done', 'drop', 'reopen', 'remove'].includes(sub)) {
|
|
212
|
+
const t = await get();
|
|
213
|
+
if (!t || t.ok === false) return fail((t && t.error) || 'the server did not answer');
|
|
214
|
+
const ref = pos[1];
|
|
215
|
+
const g = /^\d+$/.test(String(ref)) ? t.plan.goals[Number(ref) - 1] : t.plan.goals.find((x) => x.id === ref);
|
|
216
|
+
if (!g) return fail(`no goal ${ref || ''} today — ${usage}`);
|
|
217
|
+
const r = await call('POST', '/api/proactive/goals', { action: sub, id: g.id });
|
|
218
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
219
|
+
say(`${sub === 'remove' ? 'removed' : sub === 'reopen' ? 'reopened' : sub === 'drop' ? 'dropped' : 'done'}: ${g.text}`);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
if (sub === 'plan') {
|
|
223
|
+
const ws = await wsOfRepo();
|
|
224
|
+
if (!ws) return fail('this is not inside a registered workspace — open it with pmos first, or use --repo PATH');
|
|
225
|
+
const r = await call('POST', '/api/proactive/goals/plan', { ws });
|
|
226
|
+
if (!r || r.ok === false) return fail(`not started — ${(r && r.error) || 'the server did not answer'}`);
|
|
227
|
+
say(`${r.seeded ? `scheduled the morning brief daily at ${r.schedule.time}, and ` : ''}started a morning-brief session to plan ${r.goals} goal(s). The plan shows when it submits the brief.`);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
if (sub !== 'list') return fail(usage);
|
|
231
|
+
const t = await get();
|
|
232
|
+
if (!t || t.ok === false) return fail((t && t.error) || 'the server did not answer');
|
|
233
|
+
const pl = t.plan;
|
|
234
|
+
if (!pl.goals.length) say(`No goals for ${t.today}. Add one with: pmos proactive goals add "…"`);
|
|
235
|
+
pl.goals.forEach((g, i) => {
|
|
236
|
+
say(`${i + 1}. ${g.id} [${g.coverage}] ${g.text}${g.initiative_id ? ' · ' + g.initiative_id : ''}`);
|
|
237
|
+
for (const e of g.context) say(` ${e.start} ${e.title}`);
|
|
238
|
+
for (const st of g.steps) say(` ${st.owner === 'pm' ? 'you ' : 'agent'} ${st.title} (${st.source}${st.mention ? ', mention' : ''}${st.prepare ? ', can prepare' : ''})`);
|
|
239
|
+
});
|
|
240
|
+
if (pl.also.length) { say('Also on your plate:'); for (const st of pl.also) say(` ${st.owner === 'pm' ? 'you ' : 'agent'} ${st.title} (${st.source})`); }
|
|
241
|
+
if (pl.brief && !pl.brief.fresh) say(`The brief is for ${pl.brief.date}, not today: it is not used. Run: pmos proactive goals plan`);
|
|
242
|
+
if (!pl.brief) say('No brief yet, so the plan uses only what Today derived. Run: pmos proactive goals plan');
|
|
243
|
+
if (pl.time) say(`Free until ${pl.time.until}: ${pl.time.free_minutes} min${pl.time.next ? ` · next: ${pl.time.next.title} at ${pl.time.next.start}` : ''}`);
|
|
244
|
+
if (pl.not_read.length) say('not read: ' + pl.not_read.map((x) => `${x.source} (${x.state})`).join(', '));
|
|
245
|
+
if (t.cards_error) say('! Today could not be read: ' + t.cards_error);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
192
248
|
if (verb === 'brief') {
|
|
193
249
|
const sub = pos[0] || 'show';
|
|
194
250
|
if (sub === 'submit') {
|
|
@@ -29,9 +29,9 @@ const DISMISS_MS = 7 * DAY;
|
|
|
29
29
|
const BASE = { needs: 100, returned: 100, review: 80, schedule: 60, stalled: 40 };
|
|
30
30
|
const OWNER = { needs: 'pm', returned: 'pm', review: 'pm', schedule: 'pm', stalled: 'agent' };
|
|
31
31
|
|
|
32
|
-
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
32
|
+
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence'];
|
|
33
33
|
// Mail is off by default: a brief reads titles and metadata of the other sources, and the PM turns mail on.
|
|
34
|
-
const BRIEF_DEFAULT = Object.freeze({ calendar: true, wispr: true, mail: false, jira: true, confluence: true });
|
|
34
|
+
const BRIEF_DEFAULT = Object.freeze({ calendar: true, wispr: true, mail: false, slack: true, jira: true, confluence: true });
|
|
35
35
|
const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, backgroundPrepare: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
|
|
36
36
|
|
|
37
37
|
const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* qr — a QR code encoder small enough to read, for one job: the remote-mode startup banner prints
|
|
4
|
+
* the phone URL as a code the phone camera can scan (workspace-phone-app). Typing
|
|
5
|
+
* http://100.x.y.z:7433/?token=<43 characters> on a phone keyboard is the step that made the
|
|
6
|
+
* tailnet flow impractical.
|
|
7
|
+
*
|
|
8
|
+
* Why it is written here and not installed: the workspace ships offline (D62) and `create-pmos
|
|
9
|
+
* workspace` installs it with `npm install -g`, so every dependency is weight on every adopter.
|
|
10
|
+
* The encoder needs about 200 lines. test.js (QR-*) checks it against published vectors: the
|
|
11
|
+
* Reed-Solomon worked example for "HELLO WORLD" 1-M, a format string, the alignment positions and
|
|
12
|
+
* the data capacities. When it was written, every version 1 to 10 at both levels and with each of
|
|
13
|
+
* the eight masks (193 codes) was also decoded by an independent reader, zxing-cpp; that reader is
|
|
14
|
+
* not a dependency, so the suite does not repeat the check.
|
|
15
|
+
*
|
|
16
|
+
* Scope, on purpose: byte mode only, error-correction level M (or L), versions 1 to 10 (up to 213
|
|
17
|
+
* bytes at M). A URL with a token is about 80 bytes. Anything longer throws rather than producing
|
|
18
|
+
* a code that cannot scan. The structure follows ISO/IEC 18004 and Project Nayuki's reference
|
|
19
|
+
* implementation (MIT), cut down to that scope.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Per version 1..10 (index 0 unused): error-correction codewords per block, and number of blocks.
|
|
23
|
+
const ECC = {
|
|
24
|
+
L: { bits: 1, perBlock: [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18], blocks: [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4] },
|
|
25
|
+
M: { bits: 0, perBlock: [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26], blocks: [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5] },
|
|
26
|
+
};
|
|
27
|
+
const MAX_VERSION = 10;
|
|
28
|
+
|
|
29
|
+
// ── GF(256) arithmetic and Reed-Solomon, over the QR polynomial x^8+x^4+x^3+x^2+1 (0x11D) ────────
|
|
30
|
+
function gfMul(x, y) {
|
|
31
|
+
let z = 0;
|
|
32
|
+
for (let i = 7; i >= 0; i--) {
|
|
33
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
34
|
+
z ^= ((y >>> i) & 1) * x;
|
|
35
|
+
}
|
|
36
|
+
return z & 0xff;
|
|
37
|
+
}
|
|
38
|
+
function rsDivisor(degree) {
|
|
39
|
+
const out = new Array(degree).fill(0);
|
|
40
|
+
out[degree - 1] = 1;
|
|
41
|
+
let root = 1;
|
|
42
|
+
for (let i = 0; i < degree; i++) {
|
|
43
|
+
for (let j = 0; j < out.length; j++) {
|
|
44
|
+
out[j] = gfMul(out[j], root);
|
|
45
|
+
if (j + 1 < out.length) out[j] ^= out[j + 1];
|
|
46
|
+
}
|
|
47
|
+
root = gfMul(root, 0x02);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function rsRemainder(data, divisor) {
|
|
52
|
+
const out = new Array(divisor.length).fill(0);
|
|
53
|
+
for (const b of data) {
|
|
54
|
+
const factor = b ^ out.shift();
|
|
55
|
+
out.push(0);
|
|
56
|
+
divisor.forEach((coef, i) => { out[i] ^= gfMul(coef, factor); });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── sizes ────────────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
function rawDataModules(ver) {
|
|
63
|
+
let n = (16 * ver + 128) * ver + 64;
|
|
64
|
+
if (ver >= 2) {
|
|
65
|
+
const align = Math.floor(ver / 7) + 2;
|
|
66
|
+
n -= (25 * align - 10) * align - 55;
|
|
67
|
+
if (ver >= 7) n -= 36;
|
|
68
|
+
}
|
|
69
|
+
return n;
|
|
70
|
+
}
|
|
71
|
+
function dataCodewords(ver, ecl) {
|
|
72
|
+
return Math.floor(rawDataModules(ver) / 8) - ECC[ecl].perBlock[ver] * ECC[ecl].blocks[ver];
|
|
73
|
+
}
|
|
74
|
+
function alignmentPositions(ver) {
|
|
75
|
+
if (ver === 1) return [];
|
|
76
|
+
const count = Math.floor(ver / 7) + 2;
|
|
77
|
+
const step = Math.ceil((ver * 4 + 4) / (count * 2 - 2)) * 2;
|
|
78
|
+
const out = [6];
|
|
79
|
+
for (let pos = ver * 4 + 17 - 7; out.length < count; pos -= step) out.splice(1, 0, pos);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── data codewords: byte-mode segment, terminator, padding ───────────────────────────────────
|
|
84
|
+
function dataBits(bytes, ver, ecl) {
|
|
85
|
+
const bits = [];
|
|
86
|
+
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
|
|
87
|
+
put(0x4, 4); // byte mode
|
|
88
|
+
put(bytes.length, ver <= 9 ? 8 : 16); // character count
|
|
89
|
+
for (const b of bytes) put(b, 8);
|
|
90
|
+
const capacity = dataCodewords(ver, ecl) * 8;
|
|
91
|
+
put(0, Math.min(4, capacity - bits.length));
|
|
92
|
+
put(0, (8 - (bits.length % 8)) % 8);
|
|
93
|
+
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8);
|
|
94
|
+
const out = [];
|
|
95
|
+
for (let i = 0; i < bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8).join(''), 2));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withEccInterleaved(data, ver, ecl) {
|
|
100
|
+
const numBlocks = ECC[ecl].blocks[ver];
|
|
101
|
+
const eccLen = ECC[ecl].perBlock[ver];
|
|
102
|
+
const raw = Math.floor(rawDataModules(ver) / 8);
|
|
103
|
+
const numShort = numBlocks - (raw % numBlocks);
|
|
104
|
+
const shortLen = Math.floor(raw / numBlocks);
|
|
105
|
+
const div = rsDivisor(eccLen);
|
|
106
|
+
const blocks = [];
|
|
107
|
+
for (let i = 0, k = 0; i < numBlocks; i++) {
|
|
108
|
+
const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
|
|
109
|
+
k += dat.length;
|
|
110
|
+
const ecc = rsRemainder(dat, div);
|
|
111
|
+
if (i < numShort) dat.push(0); // placeholder so every block has the same length
|
|
112
|
+
blocks.push(dat.concat(ecc));
|
|
113
|
+
}
|
|
114
|
+
const out = [];
|
|
115
|
+
for (let i = 0; i < blocks[0].length; i++) {
|
|
116
|
+
blocks.forEach((blk, j) => { if (i !== shortLen - eccLen || j >= numShort) out.push(blk[i]); });
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── the module grid ──────────────────────────────────────────────────────────────────────────
|
|
122
|
+
function formatBits(ecl, mask) {
|
|
123
|
+
const data = (ECC[ecl].bits << 3) | mask;
|
|
124
|
+
let rem = data;
|
|
125
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
126
|
+
return ((data << 10) | rem) ^ 0x5412;
|
|
127
|
+
}
|
|
128
|
+
function versionBits(ver) {
|
|
129
|
+
let rem = ver;
|
|
130
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
|
131
|
+
return (ver << 12) | rem;
|
|
132
|
+
}
|
|
133
|
+
const MASKS = [
|
|
134
|
+
(x, y) => (x + y) % 2 === 0,
|
|
135
|
+
(x, y) => y % 2 === 0,
|
|
136
|
+
(x) => x % 3 === 0,
|
|
137
|
+
(x, y) => (x + y) % 3 === 0,
|
|
138
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
139
|
+
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
140
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
141
|
+
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function build(codewords, ver, ecl, mask) {
|
|
145
|
+
const size = ver * 4 + 17;
|
|
146
|
+
const mod = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
147
|
+
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
148
|
+
const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; };
|
|
149
|
+
const bit = (v, i) => ((v >>> i) & 1) !== 0;
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
|
152
|
+
for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
|
|
153
|
+
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
|
|
154
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
155
|
+
const x = cx + dx; const y = cy + dy;
|
|
156
|
+
if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const al = alignmentPositions(ver);
|
|
160
|
+
for (let i = 0; i < al.length; i++) for (let j = 0; j < al.length; j++) {
|
|
161
|
+
if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) continue;
|
|
162
|
+
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
|
163
|
+
set(al[i] + dx, al[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const drawFormat = (m) => {
|
|
167
|
+
const f = formatBits(ecl, m);
|
|
168
|
+
for (let i = 0; i <= 5; i++) set(8, i, bit(f, i));
|
|
169
|
+
set(8, 7, bit(f, 6)); set(8, 8, bit(f, 7)); set(7, 8, bit(f, 8));
|
|
170
|
+
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(f, i));
|
|
171
|
+
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(f, i));
|
|
172
|
+
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(f, i));
|
|
173
|
+
set(8, size - 8, true); // the always-dark module
|
|
174
|
+
};
|
|
175
|
+
drawFormat(0); // reserve the area; redrawn with the real mask below
|
|
176
|
+
if (ver >= 7) {
|
|
177
|
+
const v = versionBits(ver);
|
|
178
|
+
for (let i = 0; i < 18; i++) {
|
|
179
|
+
const a = size - 11 + (i % 3); const b = Math.floor(i / 3);
|
|
180
|
+
set(a, b, bit(v, i)); set(b, a, bit(v, i));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Codewords in the two-column zigzag, right to left, skipping the vertical timing column.
|
|
185
|
+
let i = 0;
|
|
186
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
187
|
+
if (right === 6) right = 5;
|
|
188
|
+
for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
|
|
189
|
+
const x = right - j;
|
|
190
|
+
const y = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;
|
|
191
|
+
if (!fn[y][x] && i < codewords.length * 8) {
|
|
192
|
+
mod[y][x] = bit(codewords[i >>> 3], 7 - (i & 7));
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
198
|
+
if (!fn[y][x] && MASKS[mask](x, y)) mod[y][x] = !mod[y][x];
|
|
199
|
+
}
|
|
200
|
+
drawFormat(mask);
|
|
201
|
+
return mod;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The standard's four penalty rules, so the mask chosen is the one a camera reads most easily.
|
|
205
|
+
function penalty(mod) {
|
|
206
|
+
const size = mod.length;
|
|
207
|
+
let score = 0;
|
|
208
|
+
const lines = [];
|
|
209
|
+
for (let y = 0; y < size; y++) lines.push(mod[y]);
|
|
210
|
+
for (let x = 0; x < size; x++) lines.push(mod.map((row) => row[x]));
|
|
211
|
+
const finderLike = [true, false, true, true, true, false, true];
|
|
212
|
+
for (const line of lines) {
|
|
213
|
+
let run = 1;
|
|
214
|
+
for (let k = 1; k <= line.length; k++) {
|
|
215
|
+
if (k < line.length && line[k] === line[k - 1]) { run++; continue; }
|
|
216
|
+
if (run >= 5) score += 3 + (run - 5);
|
|
217
|
+
run = 1;
|
|
218
|
+
}
|
|
219
|
+
for (let k = 0; k + 7 <= line.length; k++) {
|
|
220
|
+
if (!finderLike.every((v, n) => line[k + n] === v)) continue;
|
|
221
|
+
const lightBefore = k >= 4 && [1, 2, 3, 4].every((n) => !line[k - n]);
|
|
222
|
+
const lightAfter = k + 11 <= line.length && [7, 8, 9, 10].every((n) => !line[k + n]);
|
|
223
|
+
if (lightBefore || lightAfter) score += 40;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let dark = 0;
|
|
227
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
228
|
+
if (mod[y][x]) dark++;
|
|
229
|
+
if (x + 1 < size && y + 1 < size) {
|
|
230
|
+
const c = mod[y][x];
|
|
231
|
+
if (mod[y][x + 1] === c && mod[y + 1][x] === c && mod[y + 1][x + 1] === c) score += 3;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
score += 10 * Math.floor(Math.abs((dark * 100) / (size * size) - 50) / 5);
|
|
235
|
+
return score;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Encode a string as a QR code. Returns the module grid: rows of booleans, true = dark, with no
|
|
240
|
+
* quiet zone (the renderer adds it).
|
|
241
|
+
* encode('http://100.64.0.1:7433/?token=abc') → level M, smallest version that fits
|
|
242
|
+
* encode(text, { ecl: 'L' }) → more capacity, less damage tolerance
|
|
243
|
+
* encode(text, { mask: 3 }) → force a mask (tests only)
|
|
244
|
+
* Throws on text longer than version 10 holds at the chosen level.
|
|
245
|
+
*/
|
|
246
|
+
function encode(text, { ecl = 'M', mask = null, minVersion = 1 } = {}) {
|
|
247
|
+
if (!ECC[ecl]) throw new Error(`qr: unsupported error-correction level ${ecl} (use M or L)`);
|
|
248
|
+
const bytes = [...Buffer.from(String(text), 'utf8')];
|
|
249
|
+
let ver = minVersion;
|
|
250
|
+
for (; ver <= MAX_VERSION; ver++) {
|
|
251
|
+
const need = 4 + (ver <= 9 ? 8 : 16) + bytes.length * 8;
|
|
252
|
+
if (need <= dataCodewords(ver, ecl) * 8) break;
|
|
253
|
+
}
|
|
254
|
+
if (ver > MAX_VERSION) throw new Error(`qr: ${bytes.length} bytes do not fit in a version-${MAX_VERSION} code at level ${ecl}`);
|
|
255
|
+
const codewords = withEccInterleaved(dataBits(bytes, ver, ecl), ver, ecl);
|
|
256
|
+
if (mask != null) return build(codewords, ver, ecl, mask);
|
|
257
|
+
let best = null; let bestScore = Infinity;
|
|
258
|
+
for (let m = 0; m < 8; m++) {
|
|
259
|
+
const grid = build(codewords, ver, ecl, m);
|
|
260
|
+
const s = penalty(grid);
|
|
261
|
+
if (s < bestScore) { best = grid; bestScore = s; }
|
|
262
|
+
}
|
|
263
|
+
return best;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Draw a module grid as terminal lines, two module rows per text line (half blocks), with the
|
|
268
|
+
* 4-module quiet zone a scanner needs.
|
|
269
|
+
* ansi: true explicit black-on-white colours, correct on any terminal theme (the default for a TTY)
|
|
270
|
+
* ansi: false no escape codes; light modules are drawn as blocks, so it reads correctly on the
|
|
271
|
+
* usual dark terminal and inverted on a light one (most phone cameras read both)
|
|
272
|
+
*/
|
|
273
|
+
function toTerminal(grid, { ansi = true } = {}) {
|
|
274
|
+
const q = 4;
|
|
275
|
+
const size = grid.length + q * 2;
|
|
276
|
+
const dark = (x, y) => {
|
|
277
|
+
const gx = x - q; const gy = y - q;
|
|
278
|
+
return gx >= 0 && gy >= 0 && gx < grid.length && gy < grid.length && grid[gy][gx];
|
|
279
|
+
};
|
|
280
|
+
const lines = [];
|
|
281
|
+
for (let y = 0; y < size; y += 2) {
|
|
282
|
+
let s = '';
|
|
283
|
+
for (let x = 0; x < size; x++) {
|
|
284
|
+
const top = dark(x, y); const bottom = y + 1 < size ? dark(x, y + 1) : false;
|
|
285
|
+
if (ansi) s += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
286
|
+
else s += !top && !bottom ? '█' : !top ? '▀' : !bottom ? '▄' : ' ';
|
|
287
|
+
}
|
|
288
|
+
lines.push(ansi ? `\x1b[30;47m${s}\x1b[0m` : s);
|
|
289
|
+
}
|
|
290
|
+
return lines;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = { encode, toTerminal, _internal: { rsDivisor, rsRemainder, formatBits, dataCodewords, alignmentPositions } };
|