kushi-agents 6.2.1 → 6.4.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.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/plugin/runners/bootstrap.mjs +51 -1
  3. package/plugin/runners/pull-email.mjs +3 -194
  4. package/plugin/runners/pull-meetings.mjs +4 -220
  5. package/plugin/runners/pull-onenote.mjs +3 -253
  6. package/plugin/runners/pull-sharepoint.mjs +3 -284
  7. package/plugin/runners/pull-teams.mjs +3 -183
  8. package/plugin/runners/refresh.mjs +361 -317
  9. package/plugin/runners/test/fixtures/refresh-dir/email.json +4 -7
  10. package/plugin/runners/test/fixtures/refresh-dir/teams.json +4 -6
  11. package/plugin/runners/test/integration/csc-pull.integration.test.mjs +160 -0
  12. package/plugin/runners/test/fixtures/email-abn-amro.json +0 -13
  13. package/plugin/runners/test/fixtures/email-novel-error.json +0 -9
  14. package/plugin/runners/test/fixtures/meetings-abn-amro.json +0 -10
  15. package/plugin/runners/test/fixtures/meetings-body-unavailable.json +0 -10
  16. package/plugin/runners/test/fixtures/onenote-abn-amro.json +0 -30
  17. package/plugin/runners/test/fixtures/onenote-partial.json +0 -21
  18. package/plugin/runners/test/fixtures/sharepoint-abn-amro.json +0 -12
  19. package/plugin/runners/test/fixtures/teams-abn-amro.json +0 -11
  20. package/plugin/runners/test/integration/pull-email.integration.test.mjs +0 -149
  21. package/plugin/runners/test/integration/pull-meetings.integration.test.mjs +0 -92
  22. package/plugin/runners/test/integration/pull-onenote.integration.test.mjs +0 -86
  23. package/plugin/runners/test/integration/pull-sharepoint.integration.test.mjs +0 -93
  24. package/plugin/runners/test/integration/pull-teams.integration.test.mjs +0 -91
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "6.2.1",
3
+ "version": "6.4.0",
4
4
  "description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@ import { writeAtomic, pathExists } from './lib/evidence.mjs';
29
29
  import { writeRefreshReport, writeBootstrapStatus, appendRunLog } from './lib/runlog.mjs';
30
30
 
31
31
  function parseArgs(argv) {
32
- const args = { force: false, dryRun: false, lookbackDays: null, interactive: false };
32
+ const args = { force: false, dryRun: false, lookbackDays: null, interactive: false, full: false, since: null };
33
33
  for (let i = 0; i < argv.length; i++) {
34
34
  const a = argv[i];
35
35
  if (a === '--project') args.project = argv[++i];
@@ -38,6 +38,8 @@ function parseArgs(argv) {
38
38
  else if (a === '--dry-run') args.dryRun = true;
39
39
  else if (a === '--lookback-days') args.lookbackDays = Number(argv[++i]);
40
40
  else if (a === '--interactive' || a === '-i') args.interactive = true;
41
+ else if (a === '--full') args.full = true;
42
+ else if (a === '--since') args.since = argv[++i];
41
43
  else if (a === '--help' || a === '-h') args.help = true;
42
44
  }
43
45
  return args;
@@ -48,6 +50,12 @@ function help() {
48
50
  'Usage: node bootstrap.mjs --project <P> --alias <A> [options]',
49
51
  '',
50
52
  'Options:',
53
+ ' --full After scaffolding, also run `discover` and `refresh',
54
+ ' --since <floor>` so that on first run you get a fully',
55
+ ' populated Evidence/ tree across all weeks back to the',
56
+ ' lookback floor (default: 2026-03-01 if --since not set).',
57
+ ' --since YYYY-MM-DD Used with --full. Lookback floor for refresh week loop.',
58
+ ' Defaults to 2026-03-01 (engagement start) if omitted.',
51
59
  ' --interactive Prompt for the 3 fields that most affect discover speed',
52
60
  ' (email folders, look-back days, OneNote notebook) and',
53
61
  ' stamp them into .kushi/config/user/m365-auth.json. Non-',
@@ -417,6 +425,15 @@ async function main() {
417
425
  } catch { /* bootstrap-report is diagnostics-only, never block */ }
418
426
  }
419
427
 
428
+ // v6.4.0: --full flag — after scaffolding, also run discover + refresh
429
+ // --since <floor> so a first-run on a fresh project produces a fully
430
+ // populated Evidence/ tree without manual orchestration.
431
+ let chainResults = null;
432
+ if (args.full && !args.dryRun) {
433
+ const since = args.since || '2026-03-01';
434
+ chainResults = await runFullChain({ project: args.project, alias: args.alias, since });
435
+ }
436
+
420
437
  emit({
421
438
  status: 'ok',
422
439
  project: root,
@@ -428,10 +445,43 @@ async function main() {
428
445
  ...(statusPath ? { status_md: path.relative(root, statusPath) } : {}),
429
446
  ...(dateFloorReport ? { date_floor: dateFloorReport } : {}),
430
447
  ...(interactiveReport ? { interactive: interactiveReport } : {}),
448
+ ...(chainResults ? { full_chain: chainResults } : {}),
431
449
  });
432
450
  return 0;
433
451
  }
434
452
 
453
+ import { spawn } from 'node:child_process';
454
+ import { fileURLToPath } from 'node:url';
455
+
456
+ async function runFullChain({ project, alias, since }) {
457
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
458
+ const out = { since, discover: null, refresh: null };
459
+
460
+ process.stderr.write(`\n[bootstrap --full] step 1/2: discover\n`);
461
+ out.discover = await spawnAndCapture(path.join(HERE, 'discover.mjs'), ['--project', project, '--alias', alias]);
462
+ process.stderr.write(`[bootstrap --full] discover exit=${out.discover.exit_code}\n`);
463
+
464
+ process.stderr.write(`[bootstrap --full] step 2/2: refresh --since ${since}\n`);
465
+ out.refresh = await spawnAndCapture(path.join(HERE, 'refresh.mjs'), ['--project', project, '--alias', alias, '--since', since]);
466
+ process.stderr.write(`[bootstrap --full] refresh exit=${out.refresh.exit_code}\n`);
467
+
468
+ return out;
469
+ }
470
+
471
+ function spawnAndCapture(runner, argv) {
472
+ return new Promise(resolve => {
473
+ const proc = spawn(process.execPath, [runner, ...argv], { stdio: ['ignore', 'pipe', 'inherit'] });
474
+ let stdout = '';
475
+ proc.stdout.on('data', d => { stdout += d.toString(); process.stderr.write(d); });
476
+ proc.on('close', code => {
477
+ let parsed = null;
478
+ const lastLine = stdout.trim().split('\n').filter(Boolean).pop();
479
+ try { parsed = lastLine ? JSON.parse(lastLine) : null; } catch { /* not JSON */ }
480
+ resolve({ exit_code: code, parsed });
481
+ });
482
+ });
483
+ }
484
+
435
485
  main().then(code => { process.exitCode = code; }).catch(e => {
436
486
  emit({ status: 'failed', errors: [{ message: e.message }] });
437
487
  process.exit(1);
@@ -1,198 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // plugin/runners/pull-email.mjs
3
- // Deterministic email pull: one mailbox folder × one ISO week per call.
4
- // Writes to Evidence/<alias>/email/<safe-folder>/<week>/.
5
- //
6
- // Usage:
7
- // node plugin/runners/pull-email.mjs --project <P> --alias <A> --entity <folder-name>
8
- // [--mailbox <upn>] [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]
3
+ // Deterministic email pull via WorkIQ (HARD RULE per workiq-only.instructions.md).
9
4
 
10
- import path from 'node:path';
11
- import { promises as fs } from 'node:fs';
12
- import YAML from 'yaml';
13
- import { assertProject, loadConfig } from './lib/config.mjs';
14
- import { sourceDir } from './lib/layout.mjs';
15
- import { writeAtomic, safeSegment } from './lib/evidence.mjs';
16
- import { fetchAllPages, fetchWithRetry, encodeODataOp } from './lib/http.mjs';
17
- import { getToken, SCOPES } from './lib/identity.mjs';
18
- import { updateCell } from './lib/ledger.mjs';
19
- import { appendRunLog } from './lib/runlog.mjs';
20
- import { enqueue, clear } from './lib/deferred.mjs';
21
- import { currentIsoMonday, ymd, parseYmd } from './lib/weeks.mjs';
22
- import { emitLearningCandidate } from './lib/learnings.mjs';
5
+ import { runCli } from './lib/csc-pull.mjs';
23
6
 
24
- const SOURCE = 'email';
25
-
26
- function parseArgs(argv) {
27
- const args = { dryRun: false, force: false };
28
- for (let i = 0; i < argv.length; i++) {
29
- const a = argv[i];
30
- if (a === '--project') args.project = argv[++i];
31
- else if (a === '--alias') args.alias = argv[++i];
32
- else if (a === '--entity') args.entity = argv[++i];
33
- else if (a === '--mailbox') args.mailbox = argv[++i];
34
- else if (a === '--week') args.week = argv[++i];
35
- else if (a === '--dry-run') args.dryRun = true;
36
- else if (a === '--force') args.force = true;
37
- else if (a === '--fixture') args.fixture = argv[++i];
38
- else if (a === '--help' || a === '-h') args.help = true;
39
- }
40
- return args;
41
- }
42
-
43
- function help() {
44
- return `Usage: node pull-email.mjs --project <P> --alias <A> --entity <folder-name>
45
- [--mailbox <upn>] [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]`;
46
- }
47
-
48
- function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
49
- function configError(msg) { const e = new Error(msg); e.exitCode = 2; return e; }
50
- function authError(msg) { const e = new Error(msg); e.exitCode = 3; return e; }
51
-
52
- async function buildClient({ mailbox, fixture }) {
53
- if (fixture) {
54
- const data = JSON.parse(await fs.readFile(fixture, 'utf8'));
55
- return makeFixtureClient(data);
56
- }
57
- const token = await getToken(SCOPES.graph).catch(e => { throw authError(`token fetch failed: ${e.message}`); });
58
- const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
59
- const base = mailbox
60
- ? `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mailbox)}`
61
- : `https://graph.microsoft.com/v1.0/me`;
62
- return {
63
- async findFolder(displayName) {
64
- const q = `${encodeODataOp('$filter')}=displayName eq '${displayName.replace(/'/g, "''")}'`;
65
- const url = `${base}/mailFolders?${q}&${encodeODataOp('$top')}=2`;
66
- const res = await fetchWithRetry(url, { headers });
67
- const body = await res.json();
68
- return (body.value && body.value[0]) || null;
69
- },
70
- async listMessages(folderId, fromIso, toIso) {
71
- const filter = `receivedDateTime ge ${fromIso} and receivedDateTime lt ${toIso}`;
72
- const url = `${base}/mailFolders/${encodeURIComponent(folderId)}/messages?${encodeODataOp('$filter')}=${encodeURIComponent(filter)}&${encodeODataOp('$orderby')}=receivedDateTime desc&${encodeODataOp('$top')}=50`;
73
- const { items } = await fetchAllPages(url, { headers });
74
- return items;
75
- },
76
- };
77
- }
78
-
79
- function makeFixtureClient(data) {
80
- const foldersByName = new Map();
81
- for (const f of (data.folders || [])) foldersByName.set(f.displayName, f);
82
- return {
83
- async findFolder(name) { return foldersByName.get(name) || null; },
84
- async listMessages(folderId, fromIso, toIso) {
85
- if (data.throwOnListMessages) {
86
- const t = data.throwOnListMessages;
87
- const e = new Error(t.message || 'fixture-throw');
88
- if (t.status) e.status = t.status;
89
- throw e;
90
- }
91
- const all = (data.messagesByFolder && data.messagesByFolder[folderId]) || [];
92
- return all.filter(m => m.receivedDateTime >= fromIso && m.receivedDateTime < toIso);
93
- },
94
- };
95
- }
96
-
97
- function weekBounds(weekStartYmd) {
98
- const start = parseYmd(weekStartYmd);
99
- const end = new Date(start);
100
- end.setDate(end.getDate() + 7);
101
- return { fromIso: start.toISOString(), toIso: end.toISOString() };
102
- }
103
-
104
- async function main() {
105
- const args = parseArgs(process.argv.slice(2));
106
- if (args.help) { console.log(help()); return 0; }
107
- if (!args.project || !args.alias || !args.entity) {
108
- console.error(help());
109
- emit({ source: SOURCE, status: 'failed', errors: [{ signature: 'bad-args', message: 'required: --project --alias --entity' }] });
110
- return 2;
111
- }
112
- const projectRoot = await assertProject(args.project).catch(e => { throw configError(e.message); });
113
- await loadConfig(projectRoot, args.alias);
114
- const weekStart = args.week || ymd(currentIsoMonday());
115
- const { fromIso, toIso } = weekBounds(weekStart);
116
-
117
- const client = await buildClient({ mailbox: args.mailbox, fixture: args.fixture });
118
- const startedAt = new Date().toISOString();
119
-
120
- const folder = await client.findFolder(args.entity).catch(e => { throw e; });
121
- if (!folder) {
122
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
123
- last_status: 'failed', last_error: `folder not found: ${args.entity}`,
124
- });
125
- emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'failed', errors: [{ signature: 'folder-not-found', message: args.entity }] });
126
- return 0;
127
- }
128
-
129
- let messages;
130
- try {
131
- messages = await client.listMessages(folder.id, fromIso, toIso);
132
- } catch (e) {
133
- const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
134
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
135
- if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
136
- if (!retryable && !args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'fetch-failed', message: e.message, status: e.status }, context: { runner: 'pull-email' } });
137
- emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
138
- return retryable ? 1 : 0;
139
- }
140
-
141
- if (messages.length === 0) {
142
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0, folder_id: folder.id, folder_name: folder.displayName });
143
- emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
144
- return 0;
145
- }
146
-
147
- const outDir = path.join(sourceDir(projectRoot, args.alias, SOURCE), safeSegment(args.entity), weekStart);
148
- const filesWritten = [];
149
- if (!args.dryRun) {
150
- const r1 = await writeAtomic(path.join(outDir, 'messages.yml'), YAML.stringify(messages));
151
- const r2 = await writeAtomic(path.join(outDir, 'index.md'), renderIndexMd({ folder, messages, weekStart, pulledAt: startedAt }), { skipIfUnchanged: false });
152
- for (const r of [r1, r2]) if (r.written !== false) filesWritten.push(path.relative(projectRoot, r.path));
153
- }
154
-
155
- await clear(projectRoot, args.alias, SOURCE, args.entity).catch(() => {});
156
-
157
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
158
- last_status: 'captured',
159
- items_pulled: messages.length,
160
- folder_id: folder.id,
161
- folder_name: folder.displayName,
162
- });
163
-
164
- if (!args.dryRun) {
165
- await appendRunLog(projectRoot, { runner: 'pull-email', alias: args.alias, entity: args.entity, week: weekStart, status: 'captured', items_pulled: messages.length });
166
- }
167
-
168
- emit({
169
- source: SOURCE, entity: args.entity, week: weekStart, status: 'captured',
170
- items_pulled: messages.length, folder_id: folder.id,
171
- files_written: filesWritten, ledger_key: `email::${args.entity}::${weekStart}`,
172
- });
173
- return 0;
174
- }
175
-
176
- function renderIndexMd({ folder, messages, weekStart, pulledAt }) {
177
- const lines = [
178
- `# Email — ${folder.displayName} — week ${weekStart}`,
179
- '',
180
- `- folder_id: ${folder.id}`,
181
- `- week_start: ${weekStart}`,
182
- `- messages: ${messages.length}`,
183
- `- pulled_at: ${pulledAt}`,
184
- '',
185
- '## Messages',
186
- ];
187
- for (const m of messages) {
188
- const from = (m.from && m.from.emailAddress && m.from.emailAddress.address) || '?';
189
- lines.push(`- [${m.receivedDateTime}] **${m.subject || '(no subject)'}** — ${from}`);
190
- }
191
- lines.push('');
192
- return lines.join('\n');
193
- }
194
-
195
- main().then(code => { process.exitCode = code; }).catch(e => {
196
- emit({ source: SOURCE, status: 'failed', errors: [{ message: e.message, code: e.exitCode || 'unknown' }] });
197
- process.exitCode = e.exitCode || 1;
198
- });
7
+ runCli('email').then(code => { process.exitCode = code; });
@@ -1,224 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // plugin/runners/pull-meetings.mjs
3
- // Deterministic meeting pull: one occurrence per call.
4
- // Verbatim transcript required at meetings/<dir>/verbatim/transcript.txt.
5
- // BODY_UNAVAILABLE (no transcript yet) defers 4h (per RETRY_MIN_AGE_MIN.meetings).
6
- //
7
- // Usage:
8
- // node plugin/runners/pull-meetings.mjs --project <P> --alias <A> --entity <joinUrl-or-meetingId>
9
- // [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]
3
+ // Deterministic meetings pull via WorkIQ. CSC weekly file emitted via runCli.
4
+ // Verbatim transcript capture is handled inside pullSource (meetings-only branch).
10
5
 
11
- import path from 'node:path';
12
- import { promises as fs } from 'node:fs';
13
- import YAML from 'yaml';
14
- import { assertProject, loadConfig } from './lib/config.mjs';
15
- import { sourceDir } from './lib/layout.mjs';
16
- import { writeAtomic } from './lib/evidence.mjs';
17
- import { fetchWithRetry, encodeODataOp } from './lib/http.mjs';
18
- import { getToken, SCOPES } from './lib/identity.mjs';
19
- import { updateCell } from './lib/ledger.mjs';
20
- import { appendRunLog } from './lib/runlog.mjs';
21
- import { enqueue, clear } from './lib/deferred.mjs';
22
- import { shortHash } from './lib/dedup.mjs';
23
- import { currentIsoMonday, ymd } from './lib/weeks.mjs';
24
- import { emitLearningCandidate } from './lib/learnings.mjs';
6
+ import { runCli } from './lib/csc-pull.mjs';
25
7
 
26
- const SOURCE = 'meetings';
27
-
28
- function parseArgs(argv) {
29
- const args = { dryRun: false, force: false };
30
- for (let i = 0; i < argv.length; i++) {
31
- const a = argv[i];
32
- if (a === '--project') args.project = argv[++i];
33
- else if (a === '--alias') args.alias = argv[++i];
34
- else if (a === '--entity') args.entity = argv[++i];
35
- else if (a === '--week') args.week = argv[++i];
36
- else if (a === '--dry-run') args.dryRun = true;
37
- else if (a === '--force') args.force = true;
38
- else if (a === '--fixture') args.fixture = argv[++i];
39
- else if (a === '--help' || a === '-h') args.help = true;
40
- }
41
- return args;
42
- }
43
-
44
- function help() {
45
- return `Usage: node pull-meetings.mjs --project <P> --alias <A> --entity <joinUrl-or-meetingId>
46
- [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]`;
47
- }
48
-
49
- function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
50
- function configError(msg) { const e = new Error(msg); e.exitCode = 2; return e; }
51
- function authError(msg) { const e = new Error(msg); e.exitCode = 3; return e; }
52
-
53
- async function buildClient({ fixture }) {
54
- if (fixture) {
55
- const data = JSON.parse(await fs.readFile(fixture, 'utf8'));
56
- return makeFixtureClient(data);
57
- }
58
- const token = await getToken(SCOPES.graph).catch(e => { throw authError(`token fetch failed: ${e.message}`); });
59
- const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
60
- return {
61
- async resolveMeeting(entity) {
62
- // Treat joinUrl-shaped entities specially; otherwise treat as onlineMeeting id.
63
- if (/^https?:\/\//i.test(entity)) {
64
- const filter = `JoinWebUrl eq '${entity.replace(/'/g, "''")}'`;
65
- const url = `https://graph.microsoft.com/v1.0/me/onlineMeetings?${encodeODataOp('$filter')}=${encodeURIComponent(filter)}`;
66
- const res = await fetchWithRetry(url, { headers });
67
- const body = await res.json();
68
- return (body.value && body.value[0]) || null;
69
- }
70
- const res = await fetchWithRetry(`https://graph.microsoft.com/v1.0/me/onlineMeetings/${encodeURIComponent(entity)}`, { headers });
71
- return await res.json();
72
- },
73
- async getTranscript(meetingId) {
74
- // List transcripts, fetch most recent .vtt content.
75
- const listUrl = `https://graph.microsoft.com/v1.0/me/onlineMeetings/${encodeURIComponent(meetingId)}/transcripts`;
76
- let res;
77
- try { res = await fetchWithRetry(listUrl, { headers }); } catch (e) { if (e.status === 404) return null; throw e; }
78
- const body = await res.json();
79
- const list = body.value || [];
80
- if (list.length === 0) return null;
81
- list.sort((a, b) => String(b.createdDateTime || '').localeCompare(String(a.createdDateTime || '')));
82
- const tx = list[0];
83
- const contentUrl = `https://graph.microsoft.com/v1.0/me/onlineMeetings/${encodeURIComponent(meetingId)}/transcripts/${encodeURIComponent(tx.id)}/content?$format=text/vtt`;
84
- const res2 = await fetchWithRetry(contentUrl, { headers: { ...headers, Accept: 'text/vtt' } });
85
- return await res2.text();
86
- },
87
- };
88
- }
89
-
90
- function makeFixtureClient(data) {
91
- return {
92
- async resolveMeeting(_entity) { return data.meeting || null; },
93
- async getTranscript(_id) {
94
- if (data.transcript === null) return null;
95
- return data.transcript || null;
96
- },
97
- };
98
- }
99
-
100
- async function main() {
101
- const args = parseArgs(process.argv.slice(2));
102
- if (args.help) { console.log(help()); return 0; }
103
- if (!args.project || !args.alias || !args.entity) {
104
- console.error(help());
105
- emit({ source: SOURCE, status: 'failed', errors: [{ signature: 'bad-args', message: 'required: --project --alias --entity' }] });
106
- return 2;
107
- }
108
- const projectRoot = await assertProject(args.project).catch(e => { throw configError(e.message); });
109
- await loadConfig(projectRoot, args.alias);
110
- const weekStart = args.week || ymd(currentIsoMonday());
111
-
112
- // Validate entity is a Teams meeting join URL or meeting ID (not a meeting subject
113
- // from discover fallback). Subjects produce 403/404 from Graph onlineMeetings.
114
- // Fixture mode bypasses validation.
115
- if (!args.fixture && !/^https?:\/\//i.test(args.entity) && !/^MS[a-z0-9_\-=]+$/i.test(args.entity)) {
116
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
117
- last_status: 'failed',
118
- last_error: 'entity is not a Teams meeting join URL or meeting ID (subject fallback from discover)',
119
- });
120
- if (!args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'entity-not-join-url', message: 'expected meetup-join URL or meeting ID', status: null }, context: { runner: 'pull-meetings' } });
121
- emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'failed', errors: [{ signature: 'entity-not-join-url', message: `entity '${args.entity}' is not a Teams meeting join URL. Discover stored a meeting-subject fallback. Replace with a join URL like 'https://teams.microsoft.com/l/meetup-join/...' in boundaries.yml meetings.joinUrls.` }] });
122
- return 0;
123
- }
124
-
125
- const client = await buildClient({ fixture: args.fixture });
126
- const startedAt = new Date().toISOString();
127
-
128
- let meeting;
129
- try { meeting = await client.resolveMeeting(args.entity); }
130
- catch (e) {
131
- const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
132
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
133
- if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
134
- if (!retryable && !args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'fetch-failed', message: e.message, status: e.status }, context: { runner: 'pull-meetings' } });
135
- emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
136
- return retryable ? 1 : 0;
137
- }
138
-
139
- if (!meeting) {
140
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0 });
141
- emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
142
- return 0;
143
- }
144
-
145
- const meetingId = meeting.id || shortHash(args.entity);
146
- const dirHash = shortHash(args.entity);
147
- const outDir = path.join(sourceDir(projectRoot, args.alias, SOURCE), dirHash);
148
-
149
- let transcript;
150
- try { transcript = await client.getTranscript(meetingId); }
151
- catch (e) { transcript = null; }
152
-
153
- const filesWritten = [];
154
- if (!args.dryRun) {
155
- const r1 = await writeAtomic(path.join(outDir, 'meeting.yml'), YAML.stringify(meeting));
156
- if (r1.written !== false) filesWritten.push(path.relative(projectRoot, r1.path));
157
- }
158
-
159
- if (transcript == null) {
160
- // BODY_UNAVAILABLE — defer 4h
161
- if (!args.dryRun) {
162
- await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'body-unavailable', reason: 'transcript not yet available' });
163
- }
164
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
165
- last_status: 'body-unavailable',
166
- meeting_id: meetingId,
167
- transcript_available: false,
168
- });
169
- emit({
170
- source: SOURCE, entity: args.entity, week: weekStart, status: 'body-unavailable',
171
- meeting_id: meetingId, files_written: filesWritten,
172
- errors: [{ signature: 'body-unavailable', action_taken: 'deferred-retry-marker-written', retry_after_min: 240 }],
173
- });
174
- return 0;
175
- }
176
-
177
- if (!args.dryRun) {
178
- const r2 = await writeAtomic(path.join(outDir, 'verbatim', 'transcript.txt'), transcript);
179
- const r3 = await writeAtomic(path.join(outDir, 'index.md'), renderIndexMd({ meeting, transcriptChars: transcript.length, pulledAt: startedAt }), { skipIfUnchanged: false });
180
- for (const r of [r2, r3]) if (r.written !== false) filesWritten.push(path.relative(projectRoot, r.path));
181
- }
182
-
183
- await clear(projectRoot, args.alias, SOURCE, args.entity).catch(() => {});
184
-
185
- await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
186
- last_status: 'captured',
187
- items_pulled: 1,
188
- meeting_id: meetingId,
189
- transcript_available: true,
190
- transcript_chars: transcript.length,
191
- });
192
-
193
- if (!args.dryRun) {
194
- await appendRunLog(projectRoot, { runner: 'pull-meetings', alias: args.alias, entity: args.entity, week: weekStart, status: 'captured', transcript_chars: transcript.length });
195
- }
196
-
197
- emit({
198
- source: SOURCE, entity: args.entity, week: weekStart, status: 'captured',
199
- items_pulled: 1, meeting_id: meetingId, transcript_chars: transcript.length,
200
- files_written: filesWritten, ledger_key: `meetings::${args.entity}::${weekStart}`,
201
- });
202
- return 0;
203
- }
204
-
205
- function renderIndexMd({ meeting, transcriptChars, pulledAt }) {
206
- const lines = [
207
- `# Meeting — ${meeting.subject || '(no subject)'}`,
208
- '',
209
- `- meeting_id: ${meeting.id || '?'}`,
210
- `- start: ${(meeting.startDateTime) || '?'}`,
211
- `- end: ${(meeting.endDateTime) || '?'}`,
212
- `- transcript_chars: ${transcriptChars}`,
213
- `- pulled_at: ${pulledAt}`,
214
- '',
215
- 'Verbatim transcript at `verbatim/transcript.txt`.',
216
- '',
217
- ];
218
- return lines.join('\n');
219
- }
220
-
221
- main().then(code => { process.exitCode = code; }).catch(e => {
222
- emit({ source: SOURCE, status: 'failed', errors: [{ message: e.message, code: e.exitCode || 'unknown' }] });
223
- process.exitCode = e.exitCode || 1;
224
- });
8
+ runCli('meetings').then(code => { process.exitCode = code; });