create-pmos 0.39.0 → 0.40.0

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.
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ /* doc-profiles.js — what KIND of document is this, and what must it agree with? (claims-ledger C1)
3
+ *
4
+ * A checker that treats every file the same compares things that have nothing to say about each other. So a file
5
+ * is classified FIRST, and the class decides what it is checked against. Order of evidence, strongest first:
6
+ * 1. its frontmatter `type`;
7
+ * 2. an initiative record that names it (prd_path, rubric_path, explainer_path, brief_path, doc_path);
8
+ * 3. its filename convention (<id>-prd.md, <id>-eval-rubric.md, <id>-user-stories.md, dNN-*.md …);
9
+ * 4. a heading signature, only for the two kinds with a distinctive one (PRD, rubric).
10
+ * A markdown file that matches none is UNCLASSIFIED. It still gets internal checks (its own links, its own
11
+ * `stale_after`) and is NEVER compared with another file. "Not classified" is an honest answer; guessing is not.
12
+ *
13
+ * `REFERENCES` states, per type, which roles the type is compared with. The extraction and the checks live in
14
+ * lib/claims.js and read this table, so what a type "must agree with" is written down once.
15
+ */
16
+ const path = require('path');
17
+
18
+ const IN_SCOPE_EXT = new Set(['md', 'json', 'yaml', 'yml', 'toml']);
19
+
20
+ /* Types that have a cross-reference set. Any other `type` value is classified (it has a name) but is checked
21
+ * internally only. */
22
+ const REFERENCES = {
23
+ Initiative: ['PRD', 'Rubric', 'Reference (explainer)', 'brief or document'],
24
+ PRD: ['Initiative', 'Rubric', 'decisions it cites'],
25
+ Rubric: ['PRD', 'Initiative'],
26
+ Story: ['PRD'],
27
+ ADR: ['the decisions file'],
28
+ };
29
+
30
+ const extOf = (rel) => { const b = path.posix.basename(rel).toLowerCase(); const i = b.lastIndexOf('.'); return i > 0 ? b.slice(i + 1) : ''; };
31
+
32
+ /** Which role does an initiative record give a path? `refs` maps path → [{role, id}]. */
33
+ const ROLE_TYPE = { prd_path: 'PRD', rubric_path: 'Rubric', explainer_path: 'Reference', brief_path: 'Reference', doc_path: 'Reference' };
34
+
35
+ const NAME_RULES = [
36
+ [/-eval-rubric\.md$/i, 'Rubric'],
37
+ [/-prd\.md$/i, 'PRD'],
38
+ [/-user-stories\.md$/i, 'Story'],
39
+ [/-surface-explainer\.md$/i, 'Reference'],
40
+ [/(^|\/)d\d{1,3}-[^/]+\.md$/i, 'ADR'],
41
+ ];
42
+
43
+ function headings(text) {
44
+ const out = [];
45
+ for (const m of String(text).matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) out.push(m[1].toLowerCase());
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Classify one file.
51
+ * @param {object} a
52
+ * @param {string} a.rel repo-relative path with forward slashes
53
+ * @param {object} a.fm parsed frontmatter ({} when none)
54
+ * @param {string} a.text file text
55
+ * @param {Map} [a.named] path → [{role, id}] from initiative records
56
+ * @returns {{type:string|null, via:string, classified:boolean}}
57
+ */
58
+ function classify({ rel, fm = {}, text = '', named = new Map() }) {
59
+ const ext = extOf(rel);
60
+ if (ext !== 'md') return { type: 'Config', via: 'extension', classified: true };
61
+ // A template is a scaffold with placeholders, not a document that can agree or disagree with anything.
62
+ if (/(^|\/)templates?\//i.test(rel) || /-template\.md$/i.test(rel)) return { type: 'Template', via: 'path', classified: true };
63
+ if (fm.type) return { type: String(fm.type), via: 'frontmatter type', classified: true };
64
+ const roles = named.get(rel);
65
+ if (roles && roles.length) return { type: ROLE_TYPE[roles[0].role] || 'Reference', via: 'named by initiative ' + roles[0].id, classified: true };
66
+ for (const [re, type] of NAME_RULES) if (re.test(rel)) return { type, via: 'file name', classified: true };
67
+ const h = headings(text);
68
+ const has = (s) => h.some((x) => x.includes(s));
69
+ if (has('requirements') && has('identity')) return { type: 'PRD', via: 'headings', classified: true };
70
+ if (has('dimensions') && has('sprint contract')) return { type: 'Rubric', via: 'headings', classified: true };
71
+ return { type: null, via: 'no evidence', classified: false };
72
+ }
73
+
74
+ module.exports = { classify, extOf, headings, IN_SCOPE_EXT, REFERENCES, ROLE_TYPE };
@@ -12,7 +12,8 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const { spawnSync } = require('child_process');
15
- const { previewKind } = require('./preview-kind');
15
+ const PK = require('./preview-kind');
16
+ const { previewKind } = PK;
16
17
 
17
18
  const MAX_FILES = 200;
18
19
  const cache = new Map(); // root|id → {head, at, result}
@@ -123,12 +124,19 @@ function readAtRef(root, ref, rel) {
123
124
  const p = String(rel || '');
124
125
  if (!p || p.startsWith('/') || p.split('/').some((seg) => seg === '..' || seg === '')) return { ok: false, reason: 'not a path inside the repository' };
125
126
  const kind = previewKind(p);
126
- if (kind !== 'markdown' && kind !== 'text' && kind !== 'html') return { ok: false, reason: 'this kind of file cannot be previewed from a branch' };
127
+ // A type that is binary by nature, or opened another way (image, PDF, workbook), is refused with its reason; any
128
+ // other file is tried as text, like a read from the working tree (file-viewer-any-text).
129
+ if (kind === 'image' || kind === 'pdf' || kind === 'sheet') return { ok: false, reason: 'this kind of file is opened another way, not as text' };
130
+ const known = kind === 'markdown' || kind === 'text' || kind === 'html';
131
+ if (!known && PK.isKnownBinary(p)) return { ok: false, reason: 'this is a binary file (.' + PK.extOf(p) + ') and cannot be shown as text' };
127
132
  const r = spawnSync('git', ['-C', root, 'show', ref + ':' + p], { encoding: 'buffer', maxBuffer: MAX_BYTES + 1024, timeout: 20000 });
128
133
  if (r.error || r.status !== 0) return { ok: false, reason: 'git has no such file on ' + ref };
129
134
  if (r.stdout.length > MAX_BYTES) return { ok: false, reason: 'the file is larger than 2 MB' };
130
135
  if (r.stdout.indexOf(0) !== -1) return { ok: false, reason: 'the file is binary' };
131
- return { ok: true, kind, text: r.stdout.toString('utf8') };
136
+ let text;
137
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(r.stdout); }
138
+ catch { if (!known) return { ok: false, reason: 'the file is not valid UTF-8 text' }; text = r.stdout.toString('utf8'); }
139
+ return { ok: true, kind: known ? kind : 'text', text };
132
140
  }
133
141
 
134
142
  module.exports = { filesFor, groupOf, namesId, readAtRef };
@@ -24,7 +24,7 @@
24
24
  // Textual formats shown as ESCAPED source (never executed, R2). Broad on purpose — every entry
25
25
  // is escaped text, so breadth adds no risk (PRD open-question resolved: broad allowlist).
26
26
  const TEXT_EXT = new Set([
27
- 'txt', 'text', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'ndjson',
27
+ 'txt', 'text', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'ndjson', 'jsonl', 'jsonlines', 'ldjson',
28
28
  'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'example',
29
29
  'xml', 'rss', 'atom', // NB: .svg is classified 'image' (IMAGE_MIME, checked first) — it renders, not source
30
30
  'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat',
@@ -45,6 +45,18 @@
45
45
  ico: 'image/x-icon', apng: 'image/apng',
46
46
  };
47
47
 
48
+ // File types that are binary by nature. The reader refuses these as binary WITHOUT reading them, so a
49
+ // type that is not on any list can still be tried as text (file-viewer-any-text) while these never are.
50
+ // An unlisted extension that turns out to be binary is still caught by the NUL and UTF-8 checks.
51
+ const BINARY_EXT = new Set([
52
+ 'doc', 'docx', 'dot', 'dotx', 'ppt', 'pptx', 'potx', 'xls', 'xlsm', 'xltx', 'odt', 'ods', 'odp', 'pages', 'numbers', 'key',
53
+ 'zip', 'gz', 'tgz', 'bz2', 'xz', 'zst', 'tar', '7z', 'rar', 'jar', 'war', 'apk', 'iso', 'dmg', 'pkg', 'deb', 'rpm',
54
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a', 'class', 'pyc', 'wasm', 'node',
55
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
56
+ 'mp3', 'mp4', 'm4a', 'mov', 'wav', 'avi', 'mkv', 'webm', 'ogg', 'flac', 'aac',
57
+ 'sqlite', 'sqlite3', 'db', 'mdb', 'psd', 'ai', 'heic', 'tif', 'tiff', 'raw',
58
+ ]);
59
+
48
60
  // Extension-less files that are really text (open as source).
49
61
  const TEXT_BASENAMES = new Set(['dockerfile', 'makefile', 'license', 'licence', 'authors',
50
62
  'notice', 'copying', 'changelog', 'procfile', 'brewfile', 'gemfile', 'rakefile', 'vagrantfile']);
@@ -77,6 +89,9 @@
77
89
  return 'inert';
78
90
  }
79
91
 
92
+ /** true when the extension names a type that is binary by nature (see BINARY_EXT). */
93
+ function isKnownBinary(name) { return BINARY_EXT.has(extOf(name)); }
94
+
80
95
  /** image MIME for /api/asset, or null when the extension is not an allowlisted image. */
81
96
  function imageMime(name) {
82
97
  return IMAGE_MIME[extOf(name)] || null;
@@ -105,5 +120,5 @@
105
120
  }
106
121
 
107
122
  return { previewKind, imageMime, assetMime, isTextSource, isRepoRelativeSrc, extOf,
108
- MARKDOWN_EXT, TEXT_EXT, HTML_EXT, IMAGE_MIME };
123
+ isKnownBinary, MARKDOWN_EXT, TEXT_EXT, HTML_EXT, BINARY_EXT, IMAGE_MIME };
109
124
  });
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+ /* proactive-cli.js — `pmos proactive …`: the terminal face of Today.
3
+ *
4
+ * A thin client of the running workspace server's own routes, like schedule-cli.js: one computation, one
5
+ * feedback ledger, one settings file. Nothing here writes a file itself, and nothing here starts a session,
6
+ * sends anything or changes an initiative. With no server running the command says so and exits 1.
7
+ *
8
+ * pmos proactive today
9
+ * pmos proactive scan
10
+ * pmos proactive settings [--enabled on|off] [--trigger NAME=on|off] [--stalled-days N] [--quiet HH:MM-HH:MM|none] [--pause-learning on|off]
11
+ * pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed [--note TEXT]
12
+ * pmos proactive feedback --missed TEXT
13
+ * pmos proactive brief [show]
14
+ * pmos proactive brief submit --file PATH
15
+ * pmos proactive brief schedule [--repo PATH]
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { execFileSync } = require('child_process');
20
+ const { parseArgs } = require('./schedule-cli');
21
+
22
+ const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief'] };
23
+ const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
24
+ const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
25
+
26
+ async function run(argv, { call, say, cwd = process.cwd() }) {
27
+ const [group, verb, ...rest] = argv;
28
+ const { pos, flags } = parseArgs(rest);
29
+ const fail = (m) => { say('✗ ' + m); return 1; };
30
+ if (group !== 'proactive' || !verb || flags.help || verb === 'help') { say(`usage: pmos proactive <${COMMANDS.proactive.join('|')}> …`); return verb ? 0 : 1; }
31
+ if (!COMMANDS.proactive.includes(verb)) return fail(`unknown command "${verb}" — use ${COMMANDS.proactive.join(', ')}`);
32
+ const probe = await call('GET', '/api/proactive/settings');
33
+ if (!probe) return fail('the workspace server is not running — start it with: pmos');
34
+
35
+ if (verb === 'today') {
36
+ const t = await call('GET', '/api/proactive/today');
37
+ if (!t || t.ok === false) return fail((t && t.error) || 'the server did not answer');
38
+ if (t.off) say('The engine is off. Nothing is scanned automatically.');
39
+ if (t.quiet) say('Quiet hours: no automatic scans now.');
40
+ for (const u of t.unreadable || []) say(`not read — ${u.name || u.repo}: ${u.error}`);
41
+ if (t.empty) say('Nothing is waiting on any repository that was read.');
42
+ for (const c of t.candidates || []) {
43
+ say(`${c.id} ${c.kind} ${c.owner === 'pm' ? 'yours' : "the agent's"} ${c.title}`);
44
+ say(` ${c.why}${c.evidence ? ' — ' + c.evidence : ''}`);
45
+ }
46
+ if (t.dismissed) say(`${t.dismissed} dismissed item(s) hidden for 7 days.`);
47
+ say(t.note);
48
+ return 0;
49
+ }
50
+ if (verb === 'scan') {
51
+ const r = await call('POST', '/api/proactive/scan', {});
52
+ if (!r || r.ok === false) return fail((r && r.error) || 'the scan failed');
53
+ say(`scanned — ${r.found} waiting, ${r.appended} new${(r.unreadable || []).length ? ', ' + r.unreadable.length + ' repository not read' : ''}`);
54
+ return 0;
55
+ }
56
+ if (verb === 'settings') {
57
+ const patch = {};
58
+ if ('enabled' in flags) { const v = onOff(flags.enabled, '--enabled'); if (v.err) return fail(v.err); patch.enabled = v; }
59
+ if ('pause-learning' in flags) { const v = onOff(flags['pause-learning'], '--pause-learning'); if (v.err) return fail(v.err); patch.pauseLearning = v; }
60
+ if ('trigger' in flags) {
61
+ const m = /^([a-z]+)=(on|off)$/.exec(flags.trigger);
62
+ if (!m) return fail('--trigger takes NAME=on or NAME=off, for example needs=off');
63
+ patch.triggers = { [m[1]]: m[2] === 'on' };
64
+ }
65
+ if ('name' in flags) patch.name = flags.name;
66
+ if ('brief-scoring' in flags) { const v = onOff(flags['brief-scoring'], '--brief-scoring'); if (v.err) return fail(v.err); patch.briefScoring = v; }
67
+ if ('brief-source' in flags) {
68
+ const m = /^([a-z]+)=(on|off)$/.exec(flags['brief-source']);
69
+ if (!m) return fail(`--brief-source takes NAME=on or NAME=off, where NAME is one of ${BRIEF_SOURCES.join(', ')}`);
70
+ patch.briefSources = { [m[1]]: m[2] === 'on' };
71
+ }
72
+ if ('stalled-days' in flags) patch.stalledDays = Number(flags['stalled-days']);
73
+ if ('quiet' in flags) {
74
+ if (flags.quiet === 'none') patch.quiet = null;
75
+ else { const m = /^(\d\d:\d\d)-(\d\d:\d\d)$/.exec(flags.quiet); if (!m) return fail('--quiet takes HH:MM-HH:MM, or none'); patch.quiet = { start: m[1], end: m[2] }; }
76
+ }
77
+ let s = probe.settings;
78
+ if (Object.keys(patch).length) {
79
+ const r = await call('POST', '/api/proactive/settings', patch);
80
+ if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was changed.`);
81
+ s = r.settings;
82
+ }
83
+ say(`engine ${s.enabled ? 'on' : 'off'} · stalled after ${s.stalledDays} days · quiet ${s.quiet ? s.quiet.start + '-' + s.quiet.end : 'none'} · learning ${s.pauseLearning ? 'paused' : 'on'}`);
84
+ say('brief sources: ' + BRIEF_SOURCES.map((k) => `${k} ${s.briefSources && s.briefSources[k] ? 'on' : 'off'}`).join(', ') + ' · scoring ' + (s.briefScoring ? 'on' : 'off') + (s.name ? ' · name ' + s.name : ''));
85
+ say('watching: ' + Object.entries(s.triggers).map(([k, v]) => `${k} ${v ? 'on' : 'off'}`).join(', '));
86
+ return 0;
87
+ }
88
+ if (verb === 'brief') {
89
+ const sub = pos[0] || 'show';
90
+ if (sub === 'submit') {
91
+ if (!flags.file) return fail('usage: pmos proactive brief submit --file PATH');
92
+ let raw; try { raw = JSON.parse(fs.readFileSync(path.resolve(cwd, flags.file), 'utf8')); } catch (e) { return fail(`could not read ${flags.file} as JSON — ${e.message}`); }
93
+ const r = await call('POST', '/api/proactive/brief', raw);
94
+ if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was stored.`);
95
+ say(`stored the brief for ${r.brief.date} — ${r.brief.priorities.length} priorities, ${r.brief.worth_asking.length} worth asking`);
96
+ return 0;
97
+ }
98
+ if (sub === 'schedule') {
99
+ 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 fail('this is not inside a git repository — use --repo PATH'); }
100
+ const wl = await call('GET', '/api/workspaces');
101
+ const hit = ((wl && wl.workspaces) || []).find((w) => path.resolve(w.root) === path.resolve(root));
102
+ if (!hit) return fail(`${root} is not a registered workspace — open it with pmos first`);
103
+ const r = await call('POST', '/api/schedules/seed', { ws: hit.id, set: 'brief' });
104
+ if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}`);
105
+ if (r.created.length) say(`scheduled: ${r.created[0].name} (${r.created[0].cadence} ${r.created[0].time}) — it runs only while the workspace server runs`);
106
+ else say(`nothing to add — ${r.kept.map((k) => k.skill + ': ' + k.why).join('; ')}`);
107
+ return 0;
108
+ }
109
+ if (sub !== 'show') return fail('usage: pmos proactive brief [show|submit --file PATH|schedule]');
110
+ const r = await call('GET', '/api/proactive/brief');
111
+ if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
112
+ say(r.greeting);
113
+ if (!r.brief) { say('No brief yet. Schedule one with: pmos proactive brief schedule'); return 0; }
114
+ if (r.stale) say(`This brief is for ${r.brief.date}, not today (${r.today}). It is out of date.`);
115
+ say('sources: ' + Object.entries(r.brief.sources).map(([k, v]) => `${k} ${v}`).join(', '));
116
+ for (const e of r.brief.calendar) say(`${e.start} ${e.title}${e.with ? ' — ' + e.with : ''}`);
117
+ for (const x of r.brief.priorities) say(`${x.id} priority ${x.owner === 'pm' ? 'yours' : "the agent's"} ${x.title} (${x.confidence})\n ${x.why_now}`);
118
+ for (const x of r.brief.worth_asking) say(`${x.id} worth asking ${x.title} (${x.confidence})\n ${x.why_now}`);
119
+ return 0;
120
+ }
121
+ // feedback
122
+ const body = {};
123
+ if (flags.note) body.note = flags.note;
124
+ if (flags.missed) { body.verdict = 'missed'; body.note = flags.missed; }
125
+ else {
126
+ const [id, what] = pos;
127
+ if (!id || !what) return fail('usage: pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed [--note TEXT] or --missed TEXT');
128
+ body.candidate_id = id;
129
+ if (['opened', 'dismissed', 'acted', 'ignored'].includes(what)) body.event = what; else body.verdict = what;
130
+ }
131
+ const r = await call('POST', '/api/proactive/feedback', body);
132
+ if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was recorded.`);
133
+ say('recorded');
134
+ return 0;
135
+ }
136
+
137
+ module.exports = { run, COMMANDS };
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+ /* proactive.js — the proactive engine's pure core (initiative `proactive-engine`, slice D1).
3
+ *
4
+ * The workspace used to wait to be asked. This module notices what needs the PM, from facts the repositories already
5
+ * hold, ranks it, and validates the feedback the PM gives about it. It is pure: no I/O, no clock, no model, no network.
6
+ * The server gathers the facts (ledgers and git) and persists the records; this decides.
7
+ *
8
+ * detect(ctx) the candidates that are true NOW — a condition that has ended is simply not returned
9
+ * rank(cands) the fixed, stated order
10
+ * newSignals(...) which candidates have not been appended to signals.jsonl inside the dedupe window
11
+ * dismissedIds(...) which candidates the PM dismissed within the last seven days
12
+ * validateFeedback(body) the four verdicts, the implicit events, an optional one-line note
13
+ * normalizeSettings(raw) off, per-trigger off, quiet hours, the stall threshold, pause learning
14
+ * inQuietHours(now, quiet) whether automatic scans are held
15
+ *
16
+ * A candidate is never a status. Nothing here changes an initiative, a lifecycle line or an acceptance.
17
+ */
18
+ const crypto = require('crypto');
19
+
20
+ const TRIGGERS = ['needs', 'returned', 'review', 'schedule', 'stalled'];
21
+ const VERDICTS = ['useful', 'not-useful', 'wrong-priority', 'missed'];
22
+ const EVENTS = ['opened', 'acted', 'dismissed', 'ignored'];
23
+ const DAY = 86400000;
24
+ const DEDUPE_MS = DAY;
25
+ const DISMISS_MS = 7 * DAY;
26
+
27
+ // The fixed ranking, stated to the PM: what blocks on them first, then an unreviewed session, then a failed schedule,
28
+ // then a stalled initiative. Within a kind, older first (age in days, capped at 30).
29
+ const BASE = { needs: 100, returned: 100, review: 80, schedule: 60, stalled: 40 };
30
+ const OWNER = { needs: 'pm', returned: 'pm', review: 'pm', schedule: 'pm', stalled: 'agent' };
31
+
32
+ const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
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 });
35
+ const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
36
+
37
+ const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
38
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
39
+ const days = (ms) => Math.floor(ms / DAY);
40
+
41
+ function cand(kind, key, p) {
42
+ return { id: idOf(key), key, kind, owner: OWNER[kind], initiative_id: p.initiative_id || null, ws: p.ws || null, repo: p.repo || null,
43
+ title: clip(p.title, 120), why: clip(p.why, 200), evidence: clip(p.evidence, 240), confidence: p.confidence, since: p.since == null ? null : p.since,
44
+ open: p.open || null };
45
+ }
46
+
47
+ /**
48
+ * @param {{now:number, settings:object, repos:Array, nextStepRuns:Array}} ctx
49
+ * repos[]: {name, ws, error?, initiatives:[{id,title,active,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
50
+ * returned:[{initiative_id,note,at}], schedules:[{id,name,lastStatus,lastRun}]}
51
+ * nextStepRuns[]: {session_id, initiative_id, ws, key, ended, reviewed, at}
52
+ * @returns {{candidates:Array, unreadable:Array<{repo:string,error:string}>}}
53
+ */
54
+ function detect(ctx) {
55
+ const now = ctx.now; const st = ctx.settings || DEFAULTS; const on = (t) => st.triggers ? st.triggers[t] !== false : true;
56
+ const out = []; const unreadable = [];
57
+ for (const r of ctx.repos || []) {
58
+ if (r.error) { unreadable.push({ repo: r.name, error: clip(r.error, 200) }); continue; }
59
+ const byId = new Map((r.initiatives || []).map((i) => [i.id, i]));
60
+ if (on('needs')) {
61
+ for (const n of r.needs || []) {
62
+ const ini = byId.get(n.initiative_id);
63
+ out.push(cand('needs', `needs:${r.ws}:${n.run_id || n.initiative_id + ':' + clip(n.question, 60)}`, {
64
+ initiative_id: n.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : (n.initiative_id || 'an open item'),
65
+ why: 'An open item is waiting on you.', evidence: n.question, confidence: 0.95, since: null,
66
+ open: n.initiative_id ? { type: 'initiative', initiative_id: n.initiative_id, ws: r.ws } : null }));
67
+ }
68
+ }
69
+ if (on('returned')) {
70
+ for (const x of r.returned || []) {
71
+ const ini = byId.get(x.initiative_id);
72
+ if (ini && !ini.active) continue;
73
+ out.push(cand('returned', `returned:${r.ws}:${x.initiative_id}:${x.at || ''}`, {
74
+ initiative_id: x.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : x.initiative_id,
75
+ why: 'You returned this at the Acceptance Gate and it has not been accepted since.', evidence: x.note || 'returned', confidence: 0.95, since: x.at ? Date.parse(x.at) || null : null,
76
+ open: { type: 'initiative', initiative_id: x.initiative_id, ws: r.ws } }));
77
+ }
78
+ }
79
+ if (on('schedule')) {
80
+ for (const s of r.schedules || []) {
81
+ if (s.lastStatus !== 'fail') continue;
82
+ out.push(cand('schedule', `schedule:${s.id}:${s.lastRun || ''}`, {
83
+ ws: r.ws, repo: r.name, title: s.name || s.id, why: 'A scheduled task failed on its last run.',
84
+ evidence: `${s.name || s.id} — last run ${s.lastRun ? new Date(s.lastRun).toISOString().slice(0, 16).replace('T', ' ') : 'unknown'} reported fail`,
85
+ confidence: 0.9, since: s.lastRun ? Date.parse(s.lastRun) || null : null, open: { type: 'schedule', id: s.id, ws: r.ws } }));
86
+ }
87
+ }
88
+ if (on('stalled')) {
89
+ const limit = (st.stalledDays || DEFAULTS.stalledDays) * DAY;
90
+ for (const i of r.initiatives || []) {
91
+ if (!i.active || !i.hasRuns || i.lastRun == null) continue;
92
+ const idle = now - i.lastRun;
93
+ if (idle < limit) continue;
94
+ const d = days(idle);
95
+ out.push(cand('stalled', `stalled:${r.ws}:${i.id}`, {
96
+ initiative_id: i.id, ws: r.ws, repo: r.name, title: i.title || i.id,
97
+ why: `In progress, with no agent run for ${d} days.`, evidence: `last run ${new Date(i.lastRun).toISOString().slice(0, 10)}; the threshold is ${st.stalledDays || DEFAULTS.stalledDays} days`,
98
+ confidence: Math.min(0.85, +(0.5 + 0.05 * (d - (st.stalledDays || DEFAULTS.stalledDays))).toFixed(2)), since: i.lastRun,
99
+ open: { type: 'initiative', initiative_id: i.id, ws: r.ws } }));
100
+ }
101
+ }
102
+ }
103
+ if (on('review')) {
104
+ for (const x of ctx.nextStepRuns || []) {
105
+ if (!x.ended || x.reviewed) continue;
106
+ out.push(cand('review', `review:${x.session_id}`, {
107
+ initiative_id: x.initiative_id, ws: x.ws, repo: null, title: `${x.key || 'a step'} · ${x.initiative_id}`,
108
+ why: 'A session started from a next step has ended and has no verdict from you.', evidence: `session ${x.session_id}`, confidence: 0.9, since: x.at ? Date.parse(x.at) || null : null,
109
+ open: { type: 'review', initiative_id: x.initiative_id, ws: x.ws, session_id: x.session_id } }));
110
+ }
111
+ }
112
+ return { candidates: out, unreadable };
113
+ }
114
+
115
+ function rank(cands, now) {
116
+ const score = (c) => BASE[c.kind] + (c.since != null && now != null ? Math.min(30, Math.max(0, days(now - c.since))) : 0);
117
+ return cands.map((c) => ({ ...c, rank: score(c) })).sort((a, b) => b.rank - a.rank || (a.id < b.id ? -1 : 1))
118
+ .map((c, i) => ({ ...c, n: i + 1 }));
119
+ }
120
+
121
+ /** Candidates whose key was not already appended within the dedupe window. `recent` = signals.jsonl rows `{key, at}`. */
122
+ function newSignals(cands, recent, now) {
123
+ const seen = new Set();
124
+ for (const r of recent || []) { const t = Date.parse(r && r.at); if (r && r.key && Number.isFinite(t) && now - t < DEDUPE_MS) seen.add(r.key); }
125
+ return cands.filter((c) => !seen.has(c.key));
126
+ }
127
+
128
+ /** The ids the PM dismissed inside the dismissal window. `feedback` = proactive-feedback.jsonl rows. */
129
+ function dismissedIds(feedback, now) {
130
+ const out = new Set();
131
+ for (const f of feedback || []) {
132
+ if (!f || f.event !== 'dismissed' || !f.candidate_id) continue;
133
+ const t = Date.parse(f.at);
134
+ if (Number.isFinite(t) && now - t < DISMISS_MS) out.add(f.candidate_id);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ const ID_RE = /^[0-9a-f]{12}$/;
140
+ /** @returns {{ok:true, record:object}|{ok:false, error:string}} — a verdict, or an implicit event, never both. */
141
+ function validateFeedback(body) {
142
+ const b = body && typeof body === 'object' ? body : {};
143
+ const note = clip(b.note, 300);
144
+ const hasVerdict = b.verdict !== undefined && b.verdict !== null && b.verdict !== '';
145
+ const hasEvent = b.event !== undefined && b.event !== null && b.event !== '';
146
+ if (hasVerdict === hasEvent) return { ok: false, error: `give a verdict (${VERDICTS.join(', ')}) or an event (${EVENTS.join(', ')}), not both and not neither` };
147
+ if (hasVerdict) {
148
+ if (!VERDICTS.includes(b.verdict)) return { ok: false, error: `verdict must be one of ${VERDICTS.join(', ')}` };
149
+ if (b.verdict === 'missed') {
150
+ if (!note) return { ok: false, error: 'say what was missed: a "missed" verdict needs a note' };
151
+ if (b.candidate_id !== undefined && b.candidate_id !== null && b.candidate_id !== '' && !ID_RE.test(String(b.candidate_id))) return { ok: false, error: 'candidate_id is not a candidate id' };
152
+ return { ok: true, record: { kind: 'verdict', candidate_id: b.candidate_id || null, verdict: 'missed', note } };
153
+ }
154
+ } else if (!EVENTS.includes(b.event)) return { ok: false, error: `event must be one of ${EVENTS.join(', ')}` };
155
+ if (!ID_RE.test(String(b.candidate_id || ''))) return { ok: false, error: 'candidate_id is required and must be a candidate id' };
156
+ return hasVerdict ? { ok: true, record: { kind: 'verdict', candidate_id: b.candidate_id, verdict: b.verdict, note } }
157
+ : { ok: true, record: { kind: 'event', candidate_id: b.candidate_id, event: b.event, note } };
158
+ }
159
+
160
+ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
161
+ /** @returns {{ok:true, settings:object}|{ok:false, error:string}} — `raw` is merged over the defaults; nothing is partly applied. */
162
+ function normalizeSettings(raw) {
163
+ const r = raw && typeof raw === 'object' ? raw : {};
164
+ const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning };
165
+ if (r.enabled !== undefined) { if (typeof r.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' }; s.enabled = r.enabled; }
166
+ if (r.name !== undefined) { if (typeof r.name !== 'string' || r.name.length > 40) return { ok: false, error: 'name must be text of at most 40 characters' }; s.name = r.name.replace(/\s+/g, ' ').trim(); }
167
+ if (r.briefScoring !== undefined) { if (typeof r.briefScoring !== 'boolean') return { ok: false, error: 'briefScoring must be true or false' }; s.briefScoring = r.briefScoring; }
168
+ if (r.briefSources !== undefined) {
169
+ if (!r.briefSources || typeof r.briefSources !== 'object' || Array.isArray(r.briefSources)) return { ok: false, error: `briefSources must be an object of ${BRIEF_SOURCES.join(', ')} → true or false` };
170
+ for (const [k, v] of Object.entries(r.briefSources)) {
171
+ if (!BRIEF_SOURCES.includes(k)) return { ok: false, error: `unknown brief source "${k}" — one of ${BRIEF_SOURCES.join(', ')}` };
172
+ if (typeof v !== 'boolean') return { ok: false, error: `brief source "${k}" must be true or false` };
173
+ s.briefSources[k] = v;
174
+ }
175
+ }
176
+ if (r.pauseLearning !== undefined) { if (typeof r.pauseLearning !== 'boolean') return { ok: false, error: 'pauseLearning must be true or false' }; s.pauseLearning = r.pauseLearning; }
177
+ if (r.triggers !== undefined) {
178
+ if (!r.triggers || typeof r.triggers !== 'object' || Array.isArray(r.triggers)) return { ok: false, error: `triggers must be an object of ${TRIGGERS.join(', ')} → true or false` };
179
+ for (const [k, v] of Object.entries(r.triggers)) {
180
+ if (!TRIGGERS.includes(k)) return { ok: false, error: `unknown trigger "${k}" — one of ${TRIGGERS.join(', ')}` };
181
+ if (typeof v !== 'boolean') return { ok: false, error: `trigger "${k}" must be true or false` };
182
+ s.triggers[k] = v;
183
+ }
184
+ }
185
+ if (r.stalledDays !== undefined) {
186
+ const n = Number(r.stalledDays);
187
+ if (!Number.isInteger(n) || n < 1 || n > 90) return { ok: false, error: 'stalledDays must be a whole number from 1 to 90' };
188
+ s.stalledDays = n;
189
+ }
190
+ if (r.quiet !== undefined) {
191
+ if (r.quiet === null) s.quiet = null;
192
+ else if (!r.quiet || typeof r.quiet !== 'object' || !HHMM.test(String(r.quiet.start)) || !HHMM.test(String(r.quiet.end)) || r.quiet.start === r.quiet.end) {
193
+ return { ok: false, error: 'quiet must be null, or {start, end} as HH:MM (24-hour, local), different from each other' };
194
+ } else s.quiet = { start: r.quiet.start, end: r.quiet.end };
195
+ }
196
+ return { ok: true, settings: s };
197
+ }
198
+
199
+ /** Local time of day against a quiet window; a window that wraps midnight (22:00–07:00) is handled. */
200
+ function inQuietHours(nowMs, quiet) {
201
+ if (!quiet) return false;
202
+ const d = new Date(nowMs); const m = d.getHours() * 60 + d.getMinutes();
203
+ const p = (s) => { const [h, mm] = s.split(':').map(Number); return h * 60 + mm; };
204
+ const a = p(quiet.start); const b = p(quiet.end);
205
+ return a < b ? m >= a && m < b : m >= a || m < b;
206
+ }
207
+
208
+ module.exports = { BRIEF_SOURCES, TRIGGERS, VERDICTS, EVENTS, DEFAULTS, DEDUPE_MS, DISMISS_MS, BASE, detect, rank, newSignals, dismissedIds, validateFeedback, normalizeSettings, inQuietHours };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+ /* repo-watch.js — notice that files in a repository changed, and ask for one check (claims-ledger C8).
3
+ *
4
+ * WHY A POLL AND NOT fs.watch. A recursive watch needs Node 20 on Linux and can hit the inotify limit on a big tree,
5
+ * and it still would not see a `git checkout` or a `git pull`. The signature below changes for all of them: an edit in
6
+ * the editor, a write by an agent in a terminal, a checkout, a pull.
7
+ *
8
+ * THE SIGNATURE. `git rev-parse HEAD`, plus the porcelain status, plus the mtime and size of each dirty file — a file
9
+ * edited twice shows the same status line twice, so the status alone would miss the second edit. A folder that is not
10
+ * a git checkout uses the mtime and size of its in-scope files.
11
+ *
12
+ * DEBOUNCE. An agent writes many files in a burst. A change starts the clock; the check runs once the signature has
13
+ * stayed the same for `stableMs`. A burst that keeps changing keeps postponing, and ends as ONE run.
14
+ *
15
+ * WHAT IT NEVER DOES. It runs async git only, with read-only verbs. It opens no network connection and calls no git
16
+ * network verb. It never runs a second check for a repository while one is in flight. It checks only repositories
17
+ * `enabled(root)` says to, and the PM can switch that off per repository.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+ const { execFile } = require('child_process');
23
+
24
+ const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
25
+ const DIRTY_STAT_CAP = 500;
26
+
27
+ function git(root, args) {
28
+ return new Promise((resolve) => {
29
+ execFile('git', args, { cwd: root, maxBuffer: 32 << 20, timeout: 15000, encoding: 'utf8' }, (err, out) => resolve(err ? null : out));
30
+ });
31
+ }
32
+
33
+ /** One string that changes whenever the repository's files change. `null` when the folder cannot be read at all. */
34
+ async function signature(root, listFiles) {
35
+ const head = await git(root, ['rev-parse', 'HEAD']);
36
+ const status = head == null ? null : await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
37
+ const parts = [];
38
+ if (head != null && status != null) {
39
+ parts.push(head.trim(), sha(status));
40
+ const dirty = status.split('\0').filter((e) => e.length > 3).map((e) => e.slice(3)).slice(0, DIRTY_STAT_CAP);
41
+ for (const rel of dirty) {
42
+ try { const st = fs.statSync(path.join(root, rel)); parts.push(rel + ':' + st.mtimeMs + ':' + st.size); } catch { parts.push(rel + ':gone'); }
43
+ }
44
+ return sha(parts.join('\n'));
45
+ }
46
+ // not a git checkout: the mtime and size of every in-scope file
47
+ let files = [];
48
+ try { files = (await listFiles(root)).files; } catch { return null; }
49
+ for (const rel of files) {
50
+ try { const st = fs.statSync(path.join(root, rel)); parts.push(rel + ':' + st.mtimeMs + ':' + st.size); } catch { parts.push(rel + ':gone'); }
51
+ }
52
+ return sha(parts.join('\n'));
53
+ }
54
+
55
+ /**
56
+ * @param {object} o
57
+ * @param {function} o.repos () => [{ id, root }] the repositories that exist
58
+ * @param {function} o.enabled (root) => boolean whether to watch this one
59
+ * @param {function} o.check async ({ root, sweep }) => result one check
60
+ * @param {function} [o.signature] async (root) => string|null
61
+ * @param {function} [o.onResult] ({ id, root, ok, result|error, sweep })
62
+ * @param {function} [o.now] () => ms
63
+ * @param {number} [o.pollMs] how often the timer ticks
64
+ * @param {number} [o.stableMs] how long the signature must hold still before a check
65
+ * @param {number} [o.sweepEveryMs] how often the periodic time-rule pass runs
66
+ */
67
+ function createWatcher(o) {
68
+ const now = o.now || Date.now;
69
+ const pollMs = o.pollMs || 10000;
70
+ const stableMs = o.stableMs == null ? 8000 : o.stableMs;
71
+ const sweepEveryMs = o.sweepEveryMs || 6 * 3600 * 1000;
72
+ const sigOf = o.signature || ((root) => signature(root, o.listFiles || (async () => ({ files: [] }))));
73
+ const state = new Map(); // root → { sig, changedAt, pending, running, lastSweep, checked }
74
+ let timer = null;
75
+ let ticking = false;
76
+
77
+ async function run(id, root, st, sweep) {
78
+ st.running = true;
79
+ try {
80
+ const result = await o.check({ root, sweep });
81
+ if (o.onResult) o.onResult({ id, root, ok: !!(result && result.ok !== false), result, sweep });
82
+ } catch (e) {
83
+ if (o.onResult) o.onResult({ id, root, ok: false, error: String((e && e.message) || e).slice(0, 200), sweep });
84
+ } finally { st.running = false; st.checked = true; if (sweep) st.lastSweep = now(); }
85
+ }
86
+
87
+ /** One pass over every repository. Exposed so a test can drive it with a fake clock. */
88
+ async function tick() {
89
+ if (ticking) return;
90
+ ticking = true;
91
+ try {
92
+ const live = new Set();
93
+ for (const r of o.repos()) {
94
+ if (!o.enabled(r.root)) { state.delete(r.root); continue; }
95
+ live.add(r.root);
96
+ let st = state.get(r.root);
97
+ if (!st) { st = { sig: null, changedAt: null, pending: false, running: false, lastSweep: now(), checked: false }; state.set(r.root, st); }
98
+ if (st.running) continue; // never two checks at once for one repository
99
+ const sig = await sigOf(r.root);
100
+ if (sig == null) continue;
101
+ if (!st.checked && !st.pending && st.sig == null) { st.sig = sig; await run(r.id, r.root, st, false); continue; } // first look: one check
102
+ if (sig !== st.sig) { st.sig = sig; st.changedAt = now(); st.pending = true; continue; }
103
+ if (st.pending && now() - st.changedAt >= stableMs) { st.pending = false; await run(r.id, r.root, st, false); continue; }
104
+ if (!st.pending && now() - st.lastSweep >= sweepEveryMs) await run(r.id, r.root, st, true);
105
+ }
106
+ for (const root of Array.from(state.keys())) if (!live.has(root)) state.delete(root);
107
+ } finally { ticking = false; }
108
+ }
109
+
110
+ function start() {
111
+ if (timer) return stop;
112
+ timer = setInterval(() => { tick().catch(() => { /* a failed tick is retried on the next one */ }); }, pollMs);
113
+ if (timer.unref) timer.unref();
114
+ return stop;
115
+ }
116
+ function stop() { if (timer) { clearInterval(timer); timer = null; } }
117
+ return { tick, start, stop, _state: state };
118
+ }
119
+
120
+ module.exports = { createWatcher, signature };
@@ -91,16 +91,19 @@ function getRepo(stateDir, root) {
91
91
  return {
92
92
  consent: !!(r && r.consent === true),
93
93
  exclude: r && Array.isArray(r.exclude) ? r.exclude.filter((x) => typeof x === 'string') : [],
94
+ // claims-ledger: keep this repository checked as its files change. Default OFF — switching it on makes the
95
+ // workspace append to the repository's claims.jsonl, so it is the PM's press and never a surprise.
96
+ watch: !!(r && r.watch === true),
94
97
  };
95
98
  }
96
99
 
97
- function setRepo(stateDir, root, { consent, exclude }) {
100
+ function setRepo(stateDir, root, { consent, exclude, watch }) {
98
101
  const cur = read(stateDir);
99
102
  const prev = getRepo(stateDir, root);
100
103
  const ex = Array.isArray(exclude)
101
104
  ? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
102
105
  : prev.exclude;
103
- const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex } };
106
+ const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch } };
104
107
  return write(stateDir, { key: cur.key, repos });
105
108
  }
106
109