kushi-agents 6.1.2 → 6.2.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 (86) hide show
  1. package/package.json +1 -1
  2. package/plugin/instructions/agentskills-compliance.instructions.md +144 -0
  3. package/plugin/instructions/dashboard-artifact.instructions.md +132 -0
  4. package/plugin/instructions/guided-tour.instructions.md +100 -0
  5. package/plugin/instructions/karpathy-state-layout.instructions.md +124 -0
  6. package/plugin/instructions/schema-evolve.instructions.md +73 -0
  7. package/plugin/instructions/skill-authoring.instructions.md +147 -0
  8. package/plugin/instructions/skill-evals.instructions.md +130 -0
  9. package/plugin/runners/bootstrap.mjs +55 -22
  10. package/plugin/runners/lib/runlog.mjs +153 -6
  11. package/plugin/runners/migrate-to-v550.mjs +192 -0
  12. package/plugin/runners/pull-email.mjs +194 -3
  13. package/plugin/runners/pull-meetings.mjs +207 -4
  14. package/plugin/runners/pull-onenote.mjs +239 -3
  15. package/plugin/runners/pull-sharepoint.mjs +284 -3
  16. package/plugin/runners/pull-state.mjs +297 -0
  17. package/plugin/runners/pull-teams.mjs +170 -3
  18. package/plugin/runners/refresh.mjs +9 -1
  19. package/plugin/runners/test/fixtures/email-abn-amro.json +13 -0
  20. package/plugin/runners/test/fixtures/email-novel-error.json +9 -0
  21. package/plugin/runners/test/fixtures/meetings-abn-amro.json +10 -0
  22. package/plugin/runners/test/fixtures/meetings-body-unavailable.json +10 -0
  23. package/plugin/runners/test/fixtures/onenote-abn-amro.json +30 -0
  24. package/plugin/runners/test/fixtures/onenote-partial.json +21 -0
  25. package/plugin/runners/test/fixtures/refresh-dir/email.json +7 -4
  26. package/plugin/runners/test/fixtures/refresh-dir/teams.json +6 -4
  27. package/plugin/runners/test/fixtures/sharepoint-abn-amro.json +12 -0
  28. package/plugin/runners/test/fixtures/teams-abn-amro.json +11 -0
  29. package/plugin/runners/test/integration/migrate-to-v550.integration.test.mjs +138 -0
  30. package/plugin/runners/test/integration/pull-email.integration.test.mjs +149 -0
  31. package/plugin/runners/test/integration/pull-meetings.integration.test.mjs +92 -0
  32. package/plugin/runners/test/integration/pull-onenote.integration.test.mjs +86 -0
  33. package/plugin/runners/test/integration/pull-sharepoint.integration.test.mjs +93 -0
  34. package/plugin/runners/test/integration/pull-teams.integration.test.mjs +91 -0
  35. package/plugin/runners/test/unit/runlog.test.mjs +1 -1
  36. package/plugin/skills/build-state/SKILL.md +195 -0
  37. package/plugin/skills/build-state/evals/evals.json +31 -0
  38. package/plugin/skills/dashboard/SKILL.md +132 -0
  39. package/plugin/skills/dashboard/evals/evals.json +33 -0
  40. package/plugin/skills/lint-state/.created-by-skill-creator +0 -0
  41. package/plugin/skills/lint-state/SKILL.md +98 -0
  42. package/plugin/skills/lint-state/evals/evals.json +34 -0
  43. package/plugin/skills/lint-state/lint.ps1 +218 -0
  44. package/plugin/skills/promote/.created-by-skill-creator +1 -0
  45. package/plugin/skills/promote/SKILL.md +125 -0
  46. package/plugin/skills/promote/evals/evals.json +35 -0
  47. package/plugin/skills/schema-evolve/.created-by-skill-creator +0 -0
  48. package/plugin/skills/schema-evolve/SKILL.md +106 -0
  49. package/plugin/skills/schema-evolve/evals/evals.json +37 -0
  50. package/plugin/skills/skill-checker/SKILL.md +136 -0
  51. package/plugin/skills/skill-checker/check-skill.ps1 +416 -0
  52. package/plugin/skills/skill-checker/evals/evals.json +41 -0
  53. package/plugin/skills/skill-creator/SKILL.md +134 -0
  54. package/plugin/skills/skill-creator/evals/evals.json +40 -0
  55. package/plugin/skills/skill-creator/generate-eval-review.ps1 +101 -0
  56. package/plugin/skills/skill-creator/optimize-description.ps1 +87 -0
  57. package/plugin/skills/skill-creator/scaffold.ps1 +180 -0
  58. package/plugin/skills/skill-creator/templates/evals-starter.template.json +27 -0
  59. package/plugin/skills/skill-creator/templates/gotchas-stub.template.md +9 -0
  60. package/plugin/skills/skill-creator/templates/skill-skeleton.template.md +28 -0
  61. package/plugin/skills/teach/.created-by-skill-creator +0 -0
  62. package/plugin/skills/teach/SKILL.md +79 -0
  63. package/plugin/skills/teach/evals/evals.json +59 -0
  64. package/plugin/skills/tour/SKILL.md +85 -0
  65. package/plugin/skills/tour/build-tour.ps1 +185 -0
  66. package/plugin/skills/tour/evals/evals.json +33 -0
  67. package/plugin/templates/state/00_overview.template.md +44 -0
  68. package/plugin/templates/state/01_decisions.template.md +41 -0
  69. package/plugin/templates/state/02_stakeholders.template.md +48 -0
  70. package/plugin/templates/state/03_architecture-and-solution.template.md +56 -0
  71. package/plugin/templates/state/04_workshops-and-key-meetings.template.md +43 -0
  72. package/plugin/templates/state/05_action-items.template.md +29 -0
  73. package/plugin/templates/state/06_risks-and-issues.template.md +43 -0
  74. package/plugin/templates/state/07_timeline-and-milestones.template.md +45 -0
  75. package/plugin/templates/state/08_artifacts-and-deliverables.template.md +55 -0
  76. package/plugin/templates/state/09_open-questions.template.md +62 -0
  77. package/plugin/templates/state/AGENTS.template.md +33 -0
  78. package/plugin/templates/state/CLAUDE.template.md +33 -0
  79. package/plugin/templates/state/README.md +41 -0
  80. package/plugin/templates/state/answers.README.md +7 -0
  81. package/plugin/templates/state/hot.template.md +12 -0
  82. package/plugin/templates/state/index.template.md +41 -0
  83. package/plugin/templates/state/log.template.md +14 -0
  84. package/plugin/templates/state/page.template.md +22 -0
  85. package/plugin/templates/state/review-queue.template.md +10 -0
  86. package/plugin/runners/test/integration/csc-pull.integration.test.mjs +0 -160
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // plugin/runners/migrate-to-v550.mjs
3
+ //
4
+ // Migrate a v4.x project layout to v5.5.0 deterministic-runner shape.
5
+ //
6
+ // What it does:
7
+ // 1. Reads <project>/integrations.yml.
8
+ // 2. If integrations.yml#boundaries.{email,teams,meetings,onenote} exists,
9
+ // splits it into Evidence/<alias>/boundaries.yml (per-user) and rewrites
10
+ // integrations.yml to keep only PROJECT-SHARED keys (crm, ado, sharepoint).
11
+ // 3. Ensures Evidence/<alias>/{email,teams,meetings,onenote,sharepoint,
12
+ // crm-notes,ado-notes,_ledger.yml,_deferred-retries,_discovery,refresh-reports}.
13
+ // 4. Ensures Evidence/_shared/{crm,ado,sharepoint}/.
14
+ // 5. Reports per-step counts on stdout as a single-line JSON.
15
+ //
16
+ // Idempotent — re-runs report `unchanged: true`.
17
+ //
18
+ // CLI:
19
+ // node migrate-to-v550.mjs --project <P> --alias <A> [--dry-run]
20
+
21
+ import { promises as fs } from 'node:fs';
22
+ import path from 'node:path';
23
+ import YAML from 'yaml';
24
+ import { aliasRoot, sharedRoot, projectRoot } from './lib/layout.mjs';
25
+
26
+ const SHARED_KEYS = new Set(['crm', 'ado', 'sharepoint']);
27
+ const USER_KEYS = ['email', 'teams', 'meetings', 'onenote'];
28
+ const SHARED_DIRS = ['crm', 'ado', 'sharepoint'];
29
+ const USER_DIRS = [
30
+ 'email', 'teams', 'meetings', 'onenote', 'sharepoint',
31
+ 'crm-notes', 'ado-notes',
32
+ '_deferred-retries', '_discovery', 'refresh-reports',
33
+ ];
34
+ const LEDGER = '_ledger.yml';
35
+ const BOUNDARIES = 'boundaries.yml';
36
+
37
+ function help() {
38
+ return `migrate-to-v550 — v4.x → v5.5.0 layout migrator
39
+
40
+ Usage: node migrate-to-v550.mjs --project <P> --alias <A> [--dry-run]`;
41
+ }
42
+
43
+ function parseArgs(argv) {
44
+ const a = { dryRun: false };
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const v = argv[i], n = argv[i + 1];
47
+ if (v === '--project') { a.project = n; i++; }
48
+ else if (v === '--alias') { a.alias = n; i++; }
49
+ else if (v === '--dry-run') { a.dryRun = true; }
50
+ else if (v === '--help' || v === '-h') { a.help = true; }
51
+ }
52
+ return a;
53
+ }
54
+
55
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
56
+
57
+ async function pathExists(p) { try { await fs.access(p); return true; } catch { return false; } }
58
+ async function readYaml(p) {
59
+ try { return YAML.parse(await fs.readFile(p, 'utf8')) ?? {}; } catch { return {}; }
60
+ }
61
+
62
+ async function ensureDir(p, dryRun) {
63
+ if (await pathExists(p)) return false;
64
+ if (!dryRun) await fs.mkdir(p, { recursive: true });
65
+ return true;
66
+ }
67
+ async function ensureFile(p, content, dryRun) {
68
+ if (await pathExists(p)) return false;
69
+ if (!dryRun) {
70
+ await fs.mkdir(path.dirname(p), { recursive: true });
71
+ await fs.writeFile(p, content, 'utf8');
72
+ }
73
+ return true;
74
+ }
75
+
76
+ export async function migrate({ project, alias, dryRun }) {
77
+ const report = {
78
+ project, alias, dry_run: !!dryRun,
79
+ boundaries_split: false,
80
+ integrations_rewritten: false,
81
+ dirs_created: [],
82
+ files_created: [],
83
+ unchanged: true,
84
+ errors: [],
85
+ };
86
+
87
+ if (!await pathExists(project)) {
88
+ report.errors.push({ signature: 'project-missing', message: `project root not found: ${project}` });
89
+ return report;
90
+ }
91
+
92
+ const intFile = path.join(projectRoot(project), 'integrations.yml');
93
+ const integrations = await readYaml(intFile);
94
+
95
+ // Step 2: split boundaries if present.
96
+ const userBoundaries = {};
97
+ let hadUserKeys = false;
98
+ if (integrations && typeof integrations.boundaries === 'object' && integrations.boundaries !== null) {
99
+ const b = integrations.boundaries;
100
+ for (const k of USER_KEYS) {
101
+ if (b[k] !== undefined) {
102
+ userBoundaries[k] = b[k];
103
+ hadUserKeys = true;
104
+ }
105
+ }
106
+ // Promote shared keys from boundaries to top-level (v5.5.0 shape).
107
+ for (const k of Object.keys(b)) {
108
+ if (SHARED_KEYS.has(k) && integrations[k] === undefined) {
109
+ integrations[k] = b[k];
110
+ }
111
+ }
112
+ // v5.5.0 has no boundaries: block in integrations.yml.
113
+ delete integrations.boundaries;
114
+ }
115
+
116
+ const aliasDir = aliasRoot(project, alias);
117
+ const userBoundariesFile = path.join(aliasDir, BOUNDARIES);
118
+
119
+ // Step 3: write per-user boundaries.yml from extracted keys.
120
+ if (hadUserKeys && !await pathExists(userBoundariesFile)) {
121
+ if (!dryRun) {
122
+ await fs.mkdir(path.dirname(userBoundariesFile), { recursive: true });
123
+ await fs.writeFile(userBoundariesFile, YAML.stringify(userBoundaries), 'utf8');
124
+ }
125
+ report.boundaries_split = true;
126
+ report.files_created.push(path.relative(project, userBoundariesFile));
127
+ report.unchanged = false;
128
+ }
129
+
130
+ // Step 4: rewrite integrations.yml if we changed it.
131
+ if (hadUserKeys) {
132
+ if (!dryRun) await fs.writeFile(intFile, YAML.stringify(integrations), 'utf8');
133
+ report.integrations_rewritten = true;
134
+ report.unchanged = false;
135
+ }
136
+
137
+ // Step 5: ensure _shared/* dirs.
138
+ const sharedDir = sharedRoot(project);
139
+ for (const d of SHARED_DIRS) {
140
+ const p = path.join(sharedDir, d);
141
+ if (await ensureDir(p, dryRun)) {
142
+ report.dirs_created.push(path.relative(project, p));
143
+ report.unchanged = false;
144
+ }
145
+ }
146
+
147
+ // Step 6: ensure per-user dirs + ledger.
148
+ for (const d of USER_DIRS) {
149
+ const p = path.join(aliasDir, d);
150
+ if (await ensureDir(p, dryRun)) {
151
+ report.dirs_created.push(path.relative(project, p));
152
+ report.unchanged = false;
153
+ }
154
+ }
155
+ const ledgerFile = path.join(aliasDir, LEDGER);
156
+ if (await ensureFile(ledgerFile, 'entries: {}\n', dryRun)) {
157
+ report.files_created.push(path.relative(project, ledgerFile));
158
+ report.unchanged = false;
159
+ }
160
+
161
+ // If no boundaries-split happened AND no per-user boundaries.yml exists, scaffold a blank one.
162
+ if (!hadUserKeys && !await pathExists(userBoundariesFile)) {
163
+ const blank = { email: { folders: [] }, teams: { chats: [] }, meetings: { joinUrls: [] }, onenote: { section_file_ids: [] }, sharepoint: { sites: [] } };
164
+ if (await ensureFile(userBoundariesFile, YAML.stringify(blank), dryRun)) {
165
+ report.files_created.push(path.relative(project, userBoundariesFile));
166
+ report.unchanged = false;
167
+ }
168
+ }
169
+
170
+ return report;
171
+ }
172
+
173
+ async function main() {
174
+ const args = parseArgs(process.argv.slice(2));
175
+ if (args.help) { console.log(help()); return 0; }
176
+ if (!args.project || !args.alias) {
177
+ emit({ status: 'failed', errors: [{ signature: 'bad-args', message: 'required: --project --alias' }] });
178
+ return 2;
179
+ }
180
+ try {
181
+ const r = await migrate({ project: args.project, alias: args.alias, dryRun: args.dryRun });
182
+ emit({ status: r.errors.length ? 'partial' : 'ok', ...r });
183
+ return r.errors.length ? 1 : 0;
184
+ } catch (e) {
185
+ emit({ status: 'failed', errors: [{ signature: 'migrate-failed', message: e.message }] });
186
+ return 1;
187
+ }
188
+ }
189
+
190
+ if (process.argv[1] && process.argv[1].endsWith('migrate-to-v550.mjs')) {
191
+ main().then(c => process.exit(c)).catch(e => { console.error(e); process.exit(1); });
192
+ }
@@ -1,7 +1,198 @@
1
1
  #!/usr/bin/env node
2
2
  // plugin/runners/pull-email.mjs
3
- // Deterministic email pull via WorkIQ (HARD RULE per workiq-only.instructions.md).
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>]
4
9
 
5
- import { runCli } from './lib/csc-pull.mjs';
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';
6
23
 
7
- runCli('email').then(code => { process.exitCode = code; });
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
+ });
@@ -1,8 +1,211 @@
1
1
  #!/usr/bin/env node
2
2
  // plugin/runners/pull-meetings.mjs
3
- // Deterministic meetings pull via WorkIQ. CSC weekly file emitted via runCli.
4
- // Verbatim transcript capture is handled inside pullSource (meetings-only branch).
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>]
5
10
 
6
- import { runCli } from './lib/csc-pull.mjs';
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';
7
25
 
8
- runCli('meetings').then(code => { process.exitCode = code; });
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
+ const client = await buildClient({ fixture: args.fixture });
113
+ const startedAt = new Date().toISOString();
114
+
115
+ let meeting;
116
+ try { meeting = await client.resolveMeeting(args.entity); }
117
+ catch (e) {
118
+ const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
119
+ await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
120
+ if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
121
+ 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' } });
122
+ emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
123
+ return retryable ? 1 : 0;
124
+ }
125
+
126
+ if (!meeting) {
127
+ await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0 });
128
+ emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
129
+ return 0;
130
+ }
131
+
132
+ const meetingId = meeting.id || shortHash(args.entity);
133
+ const dirHash = shortHash(args.entity);
134
+ const outDir = path.join(sourceDir(projectRoot, args.alias, SOURCE), dirHash);
135
+
136
+ let transcript;
137
+ try { transcript = await client.getTranscript(meetingId); }
138
+ catch (e) { transcript = null; }
139
+
140
+ const filesWritten = [];
141
+ if (!args.dryRun) {
142
+ const r1 = await writeAtomic(path.join(outDir, 'meeting.yml'), YAML.stringify(meeting));
143
+ if (r1.written !== false) filesWritten.push(path.relative(projectRoot, r1.path));
144
+ }
145
+
146
+ if (transcript == null) {
147
+ // BODY_UNAVAILABLE — defer 4h
148
+ if (!args.dryRun) {
149
+ await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'body-unavailable', reason: 'transcript not yet available' });
150
+ }
151
+ await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
152
+ last_status: 'body-unavailable',
153
+ meeting_id: meetingId,
154
+ transcript_available: false,
155
+ });
156
+ emit({
157
+ source: SOURCE, entity: args.entity, week: weekStart, status: 'body-unavailable',
158
+ meeting_id: meetingId, files_written: filesWritten,
159
+ errors: [{ signature: 'body-unavailable', action_taken: 'deferred-retry-marker-written', retry_after_min: 240 }],
160
+ });
161
+ return 0;
162
+ }
163
+
164
+ if (!args.dryRun) {
165
+ const r2 = await writeAtomic(path.join(outDir, 'verbatim', 'transcript.txt'), transcript);
166
+ const r3 = await writeAtomic(path.join(outDir, 'index.md'), renderIndexMd({ meeting, transcriptChars: transcript.length, pulledAt: startedAt }), { skipIfUnchanged: false });
167
+ for (const r of [r2, r3]) if (r.written !== false) filesWritten.push(path.relative(projectRoot, r.path));
168
+ }
169
+
170
+ await clear(projectRoot, args.alias, SOURCE, args.entity).catch(() => {});
171
+
172
+ await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
173
+ last_status: 'captured',
174
+ items_pulled: 1,
175
+ meeting_id: meetingId,
176
+ transcript_available: true,
177
+ transcript_chars: transcript.length,
178
+ });
179
+
180
+ if (!args.dryRun) {
181
+ await appendRunLog(projectRoot, { runner: 'pull-meetings', alias: args.alias, entity: args.entity, week: weekStart, status: 'captured', transcript_chars: transcript.length });
182
+ }
183
+
184
+ emit({
185
+ source: SOURCE, entity: args.entity, week: weekStart, status: 'captured',
186
+ items_pulled: 1, meeting_id: meetingId, transcript_chars: transcript.length,
187
+ files_written: filesWritten, ledger_key: `meetings::${args.entity}::${weekStart}`,
188
+ });
189
+ return 0;
190
+ }
191
+
192
+ function renderIndexMd({ meeting, transcriptChars, pulledAt }) {
193
+ const lines = [
194
+ `# Meeting — ${meeting.subject || '(no subject)'}`,
195
+ '',
196
+ `- meeting_id: ${meeting.id || '?'}`,
197
+ `- start: ${(meeting.startDateTime) || '?'}`,
198
+ `- end: ${(meeting.endDateTime) || '?'}`,
199
+ `- transcript_chars: ${transcriptChars}`,
200
+ `- pulled_at: ${pulledAt}`,
201
+ '',
202
+ 'Verbatim transcript at `verbatim/transcript.txt`.',
203
+ '',
204
+ ];
205
+ return lines.join('\n');
206
+ }
207
+
208
+ main().then(code => { process.exitCode = code; }).catch(e => {
209
+ emit({ source: SOURCE, status: 'failed', errors: [{ message: e.message, code: e.exitCode || 'unknown' }] });
210
+ process.exitCode = e.exitCode || 1;
211
+ });