kushi-agents 6.2.1 → 6.3.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.
- package/package.json +1 -1
- package/plugin/runners/pull-email.mjs +3 -194
- package/plugin/runners/pull-meetings.mjs +4 -220
- package/plugin/runners/pull-onenote.mjs +3 -253
- package/plugin/runners/pull-sharepoint.mjs +3 -284
- package/plugin/runners/pull-teams.mjs +3 -183
- package/plugin/runners/test/fixtures/refresh-dir/email.json +4 -7
- package/plugin/runners/test/fixtures/refresh-dir/teams.json +4 -6
- package/plugin/runners/test/integration/csc-pull.integration.test.mjs +160 -0
- package/plugin/runners/test/fixtures/email-abn-amro.json +0 -13
- package/plugin/runners/test/fixtures/email-novel-error.json +0 -9
- package/plugin/runners/test/fixtures/meetings-abn-amro.json +0 -10
- package/plugin/runners/test/fixtures/meetings-body-unavailable.json +0 -10
- package/plugin/runners/test/fixtures/onenote-abn-amro.json +0 -30
- package/plugin/runners/test/fixtures/onenote-partial.json +0 -21
- package/plugin/runners/test/fixtures/sharepoint-abn-amro.json +0 -12
- package/plugin/runners/test/fixtures/teams-abn-amro.json +0 -11
- package/plugin/runners/test/integration/pull-email.integration.test.mjs +0 -149
- package/plugin/runners/test/integration/pull-meetings.integration.test.mjs +0 -92
- package/plugin/runners/test/integration/pull-onenote.integration.test.mjs +0 -86
- package/plugin/runners/test/integration/pull-sharepoint.integration.test.mjs +0 -93
- package/plugin/runners/test/integration/pull-teams.integration.test.mjs +0 -91
|
@@ -1,187 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// plugin/runners/pull-teams.mjs
|
|
3
|
-
// Deterministic
|
|
4
|
-
// Writes to Evidence/<alias>/teams/<chat-hash>/<week>/.
|
|
5
|
-
//
|
|
6
|
-
// Usage:
|
|
7
|
-
// node plugin/runners/pull-teams.mjs --project <P> --alias <A> --entity <chat-id>
|
|
8
|
-
// [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]
|
|
3
|
+
// Deterministic teams pull via WorkIQ (HARD RULE per workiq-only.instructions.md).
|
|
9
4
|
|
|
10
|
-
import
|
|
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 } from './lib/evidence.mjs';
|
|
16
|
-
import { fetchAllPages, 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 { shortHash } from './lib/dedup.mjs';
|
|
22
|
-
import { currentIsoMonday, ymd, parseYmd } from './lib/weeks.mjs';
|
|
23
|
-
import { emitLearningCandidate } from './lib/learnings.mjs';
|
|
5
|
+
import { runCli } from './lib/csc-pull.mjs';
|
|
24
6
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function parseArgs(argv) {
|
|
28
|
-
const args = { dryRun: false, force: false };
|
|
29
|
-
for (let i = 0; i < argv.length; i++) {
|
|
30
|
-
const a = argv[i];
|
|
31
|
-
if (a === '--project') args.project = argv[++i];
|
|
32
|
-
else if (a === '--alias') args.alias = argv[++i];
|
|
33
|
-
else if (a === '--entity') args.entity = 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-teams.mjs --project <P> --alias <A> --entity <chat-id>
|
|
45
|
-
[--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({ 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
|
-
return {
|
|
60
|
-
async listMessages(chatId, fromIso, toIso) {
|
|
61
|
-
// Graph chats messages: server filters createdDateTime; some tenants ignore. We re-filter locally.
|
|
62
|
-
const filter = `createdDateTime ge ${fromIso} and createdDateTime lt ${toIso}`;
|
|
63
|
-
const url = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?${encodeODataOp('$filter')}=${encodeURIComponent(filter)}&${encodeODataOp('$orderby')}=createdDateTime asc&${encodeODataOp('$top')}=50`;
|
|
64
|
-
const { items } = await fetchAllPages(url, { headers });
|
|
65
|
-
return items.filter(m => m.createdDateTime >= fromIso && m.createdDateTime < toIso);
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function makeFixtureClient(data) {
|
|
71
|
-
return {
|
|
72
|
-
async listMessages(chatId, fromIso, toIso) {
|
|
73
|
-
const all = (data.messagesByChat && data.messagesByChat[chatId]) || [];
|
|
74
|
-
return all.filter(m => m.createdDateTime >= fromIso && m.createdDateTime < toIso);
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function weekBounds(weekStartYmd) {
|
|
80
|
-
const start = parseYmd(weekStartYmd);
|
|
81
|
-
const end = new Date(start);
|
|
82
|
-
end.setDate(end.getDate() + 7);
|
|
83
|
-
return { fromIso: start.toISOString(), toIso: end.toISOString() };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function main() {
|
|
87
|
-
const args = parseArgs(process.argv.slice(2));
|
|
88
|
-
if (args.help) { console.log(help()); return 0; }
|
|
89
|
-
if (!args.project || !args.alias || !args.entity) {
|
|
90
|
-
console.error(help());
|
|
91
|
-
emit({ source: SOURCE, status: 'failed', errors: [{ signature: 'bad-args', message: 'required: --project --alias --entity' }] });
|
|
92
|
-
return 2;
|
|
93
|
-
}
|
|
94
|
-
const projectRoot = await assertProject(args.project).catch(e => { throw configError(e.message); });
|
|
95
|
-
await loadConfig(projectRoot, args.alias);
|
|
96
|
-
const weekStart = args.week || ymd(currentIsoMonday());
|
|
97
|
-
const { fromIso, toIso } = weekBounds(weekStart);
|
|
98
|
-
|
|
99
|
-
// Validate entity is a Graph chat ID (not a display name from discover fallback).
|
|
100
|
-
// Display names break Graph URL construction with cryptic 400s like
|
|
101
|
-
// "Resource not found for the segment '12'". Fixture mode bypasses validation.
|
|
102
|
-
if (!args.fixture && !/^19:[\w\-+=.]+@(thread\.v[12]|unq\.gbl\.spaces)$/i.test(args.entity)) {
|
|
103
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
|
|
104
|
-
last_status: 'failed',
|
|
105
|
-
last_error: 'entity is not a Graph chat ID (display name fallback from discover)',
|
|
106
|
-
});
|
|
107
|
-
if (!args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'entity-not-chat-id', message: 'expected 19:xxx@thread.v2 or 19:xxx@unq.gbl.spaces', status: null }, context: { runner: 'pull-teams' } });
|
|
108
|
-
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'failed', errors: [{ signature: 'entity-not-chat-id', message: `entity '${args.entity}' is not a Graph chat ID. Discover stored a display-name fallback. Replace with a chat ID like '19:xxx@thread.v2' in boundaries.yml teams.chats.` }] });
|
|
109
|
-
return 0;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const client = await buildClient({ fixture: args.fixture });
|
|
113
|
-
const startedAt = new Date().toISOString();
|
|
114
|
-
|
|
115
|
-
let messages;
|
|
116
|
-
try {
|
|
117
|
-
messages = await client.listMessages(args.entity, fromIso, toIso);
|
|
118
|
-
} catch (e) {
|
|
119
|
-
const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
|
|
120
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
|
|
121
|
-
if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
|
|
122
|
-
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-teams' } });
|
|
123
|
-
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
|
|
124
|
-
return retryable ? 1 : 0;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const chatHash = shortHash(args.entity);
|
|
128
|
-
|
|
129
|
-
if (messages.length === 0) {
|
|
130
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0, chat_hash: chatHash });
|
|
131
|
-
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
|
|
132
|
-
return 0;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const outDir = path.join(sourceDir(projectRoot, args.alias, SOURCE), chatHash, weekStart);
|
|
136
|
-
const filesWritten = [];
|
|
137
|
-
if (!args.dryRun) {
|
|
138
|
-
const r1 = await writeAtomic(path.join(outDir, 'messages.yml'), YAML.stringify(messages));
|
|
139
|
-
const r2 = await writeAtomic(path.join(outDir, 'chat.yml'), YAML.stringify({ chat_id: args.entity, hash: chatHash }));
|
|
140
|
-
const r3 = await writeAtomic(path.join(outDir, 'index.md'), renderIndexMd({ chatId: args.entity, messages, weekStart, pulledAt: startedAt }), { skipIfUnchanged: false });
|
|
141
|
-
for (const r of [r1, r2, r3]) if (r.written !== false) filesWritten.push(path.relative(projectRoot, r.path));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
await clear(projectRoot, args.alias, SOURCE, args.entity).catch(() => {});
|
|
145
|
-
|
|
146
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
|
|
147
|
-
last_status: 'captured',
|
|
148
|
-
items_pulled: messages.length,
|
|
149
|
-
chat_hash: chatHash,
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
if (!args.dryRun) {
|
|
153
|
-
await appendRunLog(projectRoot, { runner: 'pull-teams', alias: args.alias, entity: args.entity, week: weekStart, status: 'captured', items_pulled: messages.length });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
emit({
|
|
157
|
-
source: SOURCE, entity: args.entity, week: weekStart, status: 'captured',
|
|
158
|
-
items_pulled: messages.length, chat_hash: chatHash,
|
|
159
|
-
files_written: filesWritten, ledger_key: `teams::${args.entity}::${weekStart}`,
|
|
160
|
-
});
|
|
161
|
-
return 0;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function renderIndexMd({ chatId, messages, weekStart, pulledAt }) {
|
|
165
|
-
const lines = [
|
|
166
|
-
`# Teams chat — ${chatId} — week ${weekStart}`,
|
|
167
|
-
'',
|
|
168
|
-
`- chat_id: ${chatId}`,
|
|
169
|
-
`- week_start: ${weekStart}`,
|
|
170
|
-
`- messages: ${messages.length}`,
|
|
171
|
-
`- pulled_at: ${pulledAt}`,
|
|
172
|
-
'',
|
|
173
|
-
'## Messages',
|
|
174
|
-
];
|
|
175
|
-
for (const m of messages) {
|
|
176
|
-
const from = (m.from && m.from.user && m.from.user.displayName) || (m.from && m.from.application && m.from.application.displayName) || '?';
|
|
177
|
-
const preview = (m.body && m.body.content || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').slice(0, 120);
|
|
178
|
-
lines.push(`- [${m.createdDateTime}] **${from}**: ${preview}`);
|
|
179
|
-
}
|
|
180
|
-
lines.push('');
|
|
181
|
-
return lines.join('\n');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
main().then(code => { process.exitCode = code; }).catch(e => {
|
|
185
|
-
emit({ source: SOURCE, status: 'failed', errors: [{ message: e.message, code: e.exitCode || 'unknown' }] });
|
|
186
|
-
process.exitCode = e.exitCode || 1;
|
|
187
|
-
});
|
|
7
|
+
runCli('teams').then(code => { process.exitCode = code; });
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
]
|
|
7
|
-
}
|
|
8
|
-
}
|
|
2
|
+
"stdout": "> [block: csc]\n> entity_id: conv-001\n> display_name: Kickoff\n> last_touched: 2026-05-26T10:00:00Z\n> participants: Alice\n> topics: kickoff\n> summary: Kickoff happened.\n",
|
|
3
|
+
"exitCode": 0,
|
|
4
|
+
"stderr": ""
|
|
5
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
}
|
|
2
|
+
"stdout": "> [block: csc]\n> entity_id: conv-001\n> display_name: Kickoff\n> last_touched: 2026-05-26T10:00:00Z\n> participants: Alice\n> topics: kickoff\n> summary: Kickoff happened.\n",
|
|
3
|
+
"exitCode": 0,
|
|
4
|
+
"stderr": ""
|
|
5
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// plugin/runners/test/integration/csc-pull.integration.test.mjs
|
|
2
|
+
//
|
|
3
|
+
// Integration tests for the WorkIQ-only CSC pull pipeline (kushi v6.1.0+).
|
|
4
|
+
// One table-driven suite covers all 5 user-scoped sources
|
|
5
|
+
// (email, teams, meetings, onenote, sharepoint) — they share lib/csc-pull.mjs,
|
|
6
|
+
// so a single contract test fixture set proves them all.
|
|
7
|
+
//
|
|
8
|
+
// Fixture shape (WorkIQ stdout, NOT Graph JSON):
|
|
9
|
+
// { "stdout": "> [block: csc]\n> entity_id: ...", "stderr": "", "exitCode": 0 }
|
|
10
|
+
|
|
11
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import YAML from 'yaml';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..', '..');
|
|
22
|
+
const FIXTURE_DIR = path.join(HERE, '..', 'fixtures');
|
|
23
|
+
|
|
24
|
+
const RUNNERS = {
|
|
25
|
+
email: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-email.mjs'),
|
|
26
|
+
teams: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-teams.mjs'),
|
|
27
|
+
meetings: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-meetings.mjs'),
|
|
28
|
+
onenote: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-onenote.mjs'),
|
|
29
|
+
sharepoint: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-sharepoint.mjs'),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ENTITIES = {
|
|
33
|
+
email: 'Inbox/102. Test Project',
|
|
34
|
+
teams: '19:abc123@thread.v2',
|
|
35
|
+
meetings: 'https://teams.microsoft.com/l/meetup-join/abc',
|
|
36
|
+
onenote: 'sectionFileId-deadbeef',
|
|
37
|
+
sharepoint: 'https://contoso.sharepoint.com/sites/test',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const WEEK = '2026-05-25';
|
|
41
|
+
|
|
42
|
+
let projectRoot;
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-csc-int-'));
|
|
45
|
+
await fs.mkdir(path.join(projectRoot, 'Evidence', 'ushak'), { recursive: true });
|
|
46
|
+
});
|
|
47
|
+
afterEach(async () => { await fs.rm(projectRoot, { recursive: true, force: true }); });
|
|
48
|
+
|
|
49
|
+
function runRunner(source, fixtureName, extra = []) {
|
|
50
|
+
const fixture = path.join(FIXTURE_DIR, fixtureName);
|
|
51
|
+
return spawnSync(process.execPath, [RUNNERS[source],
|
|
52
|
+
'--project', projectRoot, '--alias', 'ushak',
|
|
53
|
+
'--entity', ENTITIES[source],
|
|
54
|
+
'--week', WEEK, '--fixture', fixture,
|
|
55
|
+
...extra,
|
|
56
|
+
], { encoding: 'utf8' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const source of Object.keys(RUNNERS)) {
|
|
60
|
+
test(`[${source}] captured: writes weekly CSC + index + raw + ledger`, async () => {
|
|
61
|
+
const res = runRunner(source, 'csc-captured.json');
|
|
62
|
+
assert.equal(res.status, 0, `stderr: ${res.stderr}`);
|
|
63
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
64
|
+
assert.equal(result.status, 'captured');
|
|
65
|
+
assert.equal(result.items_pulled, 2);
|
|
66
|
+
assert.equal(result.source, source);
|
|
67
|
+
|
|
68
|
+
const sourceDir = path.join(projectRoot, 'Evidence', 'ushak', source);
|
|
69
|
+
const weeklyFile = path.join(sourceDir, 'weekly', `${WEEK}_${source}-csc.md`);
|
|
70
|
+
const md = await fs.readFile(weeklyFile, 'utf8');
|
|
71
|
+
assert.match(md, /Kickoff agenda/);
|
|
72
|
+
assert.match(md, /Architecture review notes/);
|
|
73
|
+
assert.match(md, new RegExp(`# ${source.toUpperCase()} CSC`));
|
|
74
|
+
|
|
75
|
+
const idx = YAML.parse(await fs.readFile(path.join(sourceDir, '_index', 'entities.yml'), 'utf8'));
|
|
76
|
+
assert.equal(idx.entities.length, 2);
|
|
77
|
+
assert.ok(idx.entities.every(e => e.id.startsWith(`${source}://`)));
|
|
78
|
+
assert.ok(idx.entities.every(e => e.weeks_touched.includes(WEEK)));
|
|
79
|
+
|
|
80
|
+
const ledger = YAML.parse(await fs.readFile(path.join(projectRoot, 'Evidence', 'ushak', '_ledger.yml'), 'utf8'));
|
|
81
|
+
const cell = ledger.entries[`${source}::${ENTITIES[source]}::${WEEK}`];
|
|
82
|
+
assert.ok(cell);
|
|
83
|
+
assert.equal(cell.last_status, 'captured');
|
|
84
|
+
assert.equal(cell.items_pulled, 2);
|
|
85
|
+
|
|
86
|
+
const rawFiles = await fs.readdir(path.join(sourceDir, '_raw'));
|
|
87
|
+
assert.equal(rawFiles.length, 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test(`[${source}] no-activity: empty stdout → status no-activity, no weekly file`, async () => {
|
|
91
|
+
const res = runRunner(source, 'csc-empty.json');
|
|
92
|
+
assert.equal(res.status, 0);
|
|
93
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
94
|
+
assert.equal(result.status, 'no-activity');
|
|
95
|
+
assert.equal(result.items_pulled, 0);
|
|
96
|
+
let exists = true;
|
|
97
|
+
try { await fs.access(path.join(projectRoot, 'Evidence', 'ushak', source, 'weekly', `${WEEK}_${source}-csc.md`)); }
|
|
98
|
+
catch { exists = false; }
|
|
99
|
+
assert.equal(exists, false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test(`[${source}] throttled: deferred + no learning candidate`, async () => {
|
|
103
|
+
const res = runRunner(source, 'csc-throttled.json');
|
|
104
|
+
assert.equal(res.status, 0);
|
|
105
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
106
|
+
assert.equal(result.status, 'deferred');
|
|
107
|
+
assert.match(result.errors[0].signature, /throttled/);
|
|
108
|
+
let learningExists = true;
|
|
109
|
+
try { await fs.access(path.join(projectRoot, 'Evidence', '_learnings-candidates')); }
|
|
110
|
+
catch { learningExists = false; }
|
|
111
|
+
assert.equal(learningExists, false, 'throttled is retryable; should NOT emit learning candidate');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test(`[${source}] novel error: failed + learning candidate`, async () => {
|
|
115
|
+
const res = runRunner(source, 'csc-novel-error.json');
|
|
116
|
+
assert.equal(res.status, 0);
|
|
117
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
118
|
+
assert.equal(result.status, 'failed');
|
|
119
|
+
const candDir = path.join(projectRoot, 'Evidence', '_learnings-candidates');
|
|
120
|
+
const entries = await fs.readdir(candDir);
|
|
121
|
+
const md = entries.filter(f => f.endsWith('.md'));
|
|
122
|
+
assert.equal(md.length, 1, `expected 1 candidate, got: ${entries.join(', ')}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test(`[${source}] citation tokens / placeholders are filtered → no-activity`, async () => {
|
|
126
|
+
const res = runRunner(source, 'csc-citation-tokens.json');
|
|
127
|
+
assert.equal(res.status, 0);
|
|
128
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
129
|
+
assert.equal(result.status, 'no-activity', `bogus turn1searchN / <value> blocks must be dropped`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test(`[${source}] dry-run: no files written, ledger empty`, async () => {
|
|
133
|
+
const res = runRunner(source, 'csc-captured.json', ['--dry-run']);
|
|
134
|
+
assert.equal(res.status, 0);
|
|
135
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
136
|
+
assert.deepEqual(result.files_written, []);
|
|
137
|
+
let ledgerExists = true;
|
|
138
|
+
try { await fs.access(path.join(projectRoot, 'Evidence', 'ushak', '_ledger.yml')); }
|
|
139
|
+
catch { ledgerExists = false; }
|
|
140
|
+
assert.equal(ledgerExists, false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test(`[${source}] missing --entity exits 2`, () => {
|
|
144
|
+
const res = spawnSync(process.execPath, [RUNNERS[source],
|
|
145
|
+
'--project', projectRoot, '--alias', 'ushak',
|
|
146
|
+
'--fixture', path.join(FIXTURE_DIR, 'csc-captured.json'),
|
|
147
|
+
], { encoding: 'utf8' });
|
|
148
|
+
assert.equal(res.status, 2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test(`[${source}] idempotent: re-running same fixture is a no-op rewrite`, async () => {
|
|
152
|
+
const r1 = runRunner(source, 'csc-captured.json');
|
|
153
|
+
assert.equal(r1.status, 0);
|
|
154
|
+
const r2 = runRunner(source, 'csc-captured.json');
|
|
155
|
+
assert.equal(r2.status, 0);
|
|
156
|
+
const idx = YAML.parse(await fs.readFile(path.join(projectRoot, 'Evidence', 'ushak', source, '_index', 'entities.yml'), 'utf8'));
|
|
157
|
+
assert.equal(idx.entities.length, 2, 'rerun must not duplicate entities');
|
|
158
|
+
assert.equal(idx.entities[0].weeks_touched.length, 1, 'rerun same week must not duplicate weeks_touched');
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"folders": [
|
|
3
|
-
{ "id": "AAMkAGI=", "displayName": "23. ABN AMRO" }
|
|
4
|
-
],
|
|
5
|
-
"messagesByFolder": {
|
|
6
|
-
"AAMkAGI=": [
|
|
7
|
-
{ "id": "m1", "subject": "Kickoff agenda", "receivedDateTime": "2026-05-25T08:00:00Z", "from": { "emailAddress": { "address": "ushak@abnamro.com", "name": "Alice" } } },
|
|
8
|
-
{ "id": "m2", "subject": "Architecture review notes", "receivedDateTime": "2026-05-27T14:30:00Z", "from": { "emailAddress": { "address": "bob@abnamro.com", "name": "Bob" } } },
|
|
9
|
-
{ "id": "m3", "subject": "Out of window — prior week", "receivedDateTime": "2026-05-18T09:00:00Z", "from": { "emailAddress": { "address": "carol@abnamro.com", "name": "Carol" } } },
|
|
10
|
-
{ "id": "m4", "subject": "Out of window — next week", "receivedDateTime": "2026-06-01T09:00:00Z", "from": { "emailAddress": { "address": "dave@abnamro.com", "name": "Dave" } } }
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"meeting": {
|
|
3
|
-
"id": "MSoxMjM0NTY3OA==",
|
|
4
|
-
"subject": "ABN AMRO — Architecture Review",
|
|
5
|
-
"startDateTime": "2026-05-26T15:00:00Z",
|
|
6
|
-
"endDateTime": "2026-05-26T16:00:00Z",
|
|
7
|
-
"joinWebUrl": "https://teams.microsoft.com/meet/31827546911768?p=YOgZHuaffeF0MGBCNt"
|
|
8
|
-
},
|
|
9
|
-
"transcript": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nAlice: Welcome everyone.\n\n00:00:05.000 --> 00:00:10.000\nBob: Thanks. Let's start with the agenda.\n\n00:00:10.000 --> 00:00:20.000\nCarol: Architecture diagram is ready for review.\n"
|
|
10
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"meeting": {
|
|
3
|
-
"id": "MSoxMjM0NTY3OA==",
|
|
4
|
-
"subject": "ABN AMRO — Discovery (transcript pending)",
|
|
5
|
-
"startDateTime": "2026-05-28T09:00:00Z",
|
|
6
|
-
"endDateTime": "2026-05-28T10:00:00Z",
|
|
7
|
-
"joinWebUrl": "https://teams.microsoft.com/meet/26457824867211?p=zdSjg6cqHoHPA8Hx11"
|
|
8
|
-
},
|
|
9
|
-
"transcript": null
|
|
10
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"section": {
|
|
3
|
-
"id": "015a0ac9-58f4-4f34-98b3-a60075b36627",
|
|
4
|
-
"displayName": "ABN AMRO"
|
|
5
|
-
},
|
|
6
|
-
"resolvedSection": {
|
|
7
|
-
"id": "015a0ac9-58f4-4f34-98b3-a60075b36627",
|
|
8
|
-
"displayName": "ABN AMRO"
|
|
9
|
-
},
|
|
10
|
-
"pages": [
|
|
11
|
-
{
|
|
12
|
-
"id": "p1",
|
|
13
|
-
"title": "Kickoff Notes",
|
|
14
|
-
"lastModifiedDateTime": "2026-05-25T10:00:00Z",
|
|
15
|
-
"content": "<html><body><h1>Kickoff</h1><p>Project goals discussed.</p></body></html>"
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"id": "p2",
|
|
19
|
-
"title": "Architecture Notes",
|
|
20
|
-
"lastModifiedDateTime": "2026-05-27T14:00:00Z",
|
|
21
|
-
"content": "<html><body><h1>Architecture</h1><p>Three-tier proposed.</p></body></html>"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"id": "p3",
|
|
25
|
-
"title": "Stale page",
|
|
26
|
-
"lastModifiedDateTime": "2026-05-18T08:00:00Z",
|
|
27
|
-
"content": "<html><body>Out of window</body></html>"
|
|
28
|
-
}
|
|
29
|
-
]
|
|
30
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"section": {
|
|
3
|
-
"id": "015a0ac9-58f4-4f34-98b3-a60075b36627",
|
|
4
|
-
"displayName": "ABN AMRO"
|
|
5
|
-
},
|
|
6
|
-
"resolvedSection": null,
|
|
7
|
-
"pages": [
|
|
8
|
-
{
|
|
9
|
-
"id": "p1",
|
|
10
|
-
"title": "Page with content",
|
|
11
|
-
"lastModifiedDateTime": "2026-05-25T10:00:00Z",
|
|
12
|
-
"content": "<html><body>OK</body></html>"
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
"id": "p2",
|
|
16
|
-
"title": "Page body unavailable",
|
|
17
|
-
"lastModifiedDateTime": "2026-05-26T11:00:00Z",
|
|
18
|
-
"content": null
|
|
19
|
-
}
|
|
20
|
-
]
|
|
21
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"itemsBySite": {
|
|
3
|
-
"https://abnamro.sharepoint.com/sites/TeamAlfred": [
|
|
4
|
-
{ "id": "01ABC", "name": "Architecture.pptx", "webUrl": "https://abnamro.sharepoint.com/sites/TeamAlfred/Architecture.pptx", "lastModifiedDateTime": "2026-05-25T11:00:00Z" },
|
|
5
|
-
{ "id": "01DEF", "name": "DiscoveryNotes.docx", "webUrl": "https://abnamro.sharepoint.com/sites/TeamAlfred/DiscoveryNotes.docx", "lastModifiedDateTime": "2026-05-27T16:00:00Z" },
|
|
6
|
-
{ "id": "01GHI", "name": "Stale.xlsx", "webUrl": "https://abnamro.sharepoint.com/sites/TeamAlfred/Stale.xlsx", "lastModifiedDateTime": "2026-05-18T09:00:00Z" }
|
|
7
|
-
],
|
|
8
|
-
"https://contoso-evil.sharepoint.com/sites/X": [
|
|
9
|
-
{ "id": "01ZZZ", "name": "Should never be reached.docx", "webUrl": "https://contoso-evil.sharepoint.com/sites/X/Y.docx", "lastModifiedDateTime": "2026-05-26T10:00:00Z" }
|
|
10
|
-
]
|
|
11
|
-
}
|
|
12
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"messagesByChat": {
|
|
3
|
-
"19:e17bf64ba941442590dae0824cc4784c@thread.v2": [
|
|
4
|
-
{ "id": "1747000000000", "createdDateTime": "2026-05-25T09:15:00Z", "from": { "user": { "displayName": "Alice" } }, "body": { "content": "<p>Status update</p>" } },
|
|
5
|
-
{ "id": "1747100000000", "createdDateTime": "2026-05-26T13:00:00Z", "from": { "user": { "displayName": "Bob" } }, "body": { "content": "<p>Will review the doc</p>" } },
|
|
6
|
-
{ "id": "1747200000000", "createdDateTime": "2026-05-28T08:30:00Z", "from": { "user": { "displayName": "Carol" } }, "body": { "content": "<p>Action items captured</p>" } },
|
|
7
|
-
{ "id": "1746000000000", "createdDateTime": "2026-05-18T10:00:00Z", "from": { "user": { "displayName": "Stale" } }, "body": { "content": "<p>Out of window</p>" } }
|
|
8
|
-
],
|
|
9
|
-
"19:empty@thread.v2": []
|
|
10
|
-
}
|
|
11
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// plugin/runners/test/integration/pull-email.integration.test.mjs
|
|
2
|
-
|
|
3
|
-
import { test, before, after } from 'node:test';
|
|
4
|
-
import assert from 'node:assert/strict';
|
|
5
|
-
import { promises as fs } from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import os from 'node:os';
|
|
8
|
-
import { spawnSync } from 'node:child_process';
|
|
9
|
-
import YAML from 'yaml';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
|
|
12
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const REPO_ROOT = path.resolve(HERE, '..', '..', '..', '..');
|
|
14
|
-
const RUNNER = path.join(REPO_ROOT, 'plugin', 'runners', 'pull-email.mjs');
|
|
15
|
-
const FIXTURE = path.join(HERE, '..', 'fixtures', 'email-abn-amro.json');
|
|
16
|
-
|
|
17
|
-
let projectRoot;
|
|
18
|
-
|
|
19
|
-
before(async () => {
|
|
20
|
-
projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-email-int-'));
|
|
21
|
-
await fs.mkdir(path.join(projectRoot, 'Evidence', 'ushak'), { recursive: true });
|
|
22
|
-
await fs.writeFile(path.join(projectRoot, 'integrations.yml'), YAML.stringify({}));
|
|
23
|
-
});
|
|
24
|
-
after(async () => { await fs.rm(projectRoot, { recursive: true, force: true }); });
|
|
25
|
-
|
|
26
|
-
function runRunner(extra = []) {
|
|
27
|
-
return spawnSync(process.execPath, [RUNNER,
|
|
28
|
-
'--project', projectRoot, '--alias', 'ushak',
|
|
29
|
-
'--entity', '23. ABN AMRO',
|
|
30
|
-
'--week', '2026-05-25', '--fixture', FIXTURE,
|
|
31
|
-
...extra,
|
|
32
|
-
], { encoding: 'utf8' });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
test('captures only messages within the ISO week window', () => {
|
|
36
|
-
const res = runRunner();
|
|
37
|
-
assert.equal(res.status, 0, `stderr: ${res.stderr}`);
|
|
38
|
-
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
39
|
-
assert.equal(result.status, 'captured');
|
|
40
|
-
assert.equal(result.items_pulled, 2);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('writes per-week directory under sanitized folder name', async () => {
|
|
44
|
-
const dir = path.join(projectRoot, 'Evidence', 'ushak', 'email', '23. ABN AMRO', '2026-05-25');
|
|
45
|
-
const msgs = YAML.parse(await fs.readFile(path.join(dir, 'messages.yml'), 'utf8'));
|
|
46
|
-
assert.equal(msgs.length, 2);
|
|
47
|
-
const md = await fs.readFile(path.join(dir, 'index.md'), 'utf8');
|
|
48
|
-
assert.match(md, /Kickoff agenda/);
|
|
49
|
-
assert.match(md, /Architecture review notes/);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('ledger entry captured with folder metadata', async () => {
|
|
53
|
-
const ledger = YAML.parse(await fs.readFile(path.join(projectRoot, 'Evidence', 'ushak', '_ledger.yml'), 'utf8'));
|
|
54
|
-
const cell = ledger.entries['email::23. ABN AMRO::2026-05-25'];
|
|
55
|
-
assert.ok(cell);
|
|
56
|
-
assert.equal(cell.last_status, 'captured');
|
|
57
|
-
assert.equal(cell.items_pulled, 2);
|
|
58
|
-
assert.equal(cell.folder_id, 'AAMkAGI=');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('folder not found → status failed (exit 0, recorded in ledger)', async () => {
|
|
62
|
-
const res = spawnSync(process.execPath, [RUNNER,
|
|
63
|
-
'--project', projectRoot, '--alias', 'ushak',
|
|
64
|
-
'--entity', 'NoSuchFolder', '--week', '2026-05-25', '--fixture', FIXTURE,
|
|
65
|
-
], { encoding: 'utf8' });
|
|
66
|
-
assert.equal(res.status, 0);
|
|
67
|
-
const r = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
68
|
-
assert.equal(r.status, 'failed');
|
|
69
|
-
assert.equal(r.errors[0].signature, 'folder-not-found');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('dry-run does not write files', async () => {
|
|
73
|
-
const projectRoot2 = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-email-dry-'));
|
|
74
|
-
await fs.mkdir(path.join(projectRoot2, 'Evidence', 'ushak'), { recursive: true });
|
|
75
|
-
await fs.writeFile(path.join(projectRoot2, 'integrations.yml'), YAML.stringify({}));
|
|
76
|
-
try {
|
|
77
|
-
const res = spawnSync(process.execPath, [RUNNER,
|
|
78
|
-
'--project', projectRoot2, '--alias', 'ushak',
|
|
79
|
-
'--entity', '23. ABN AMRO', '--week', '2026-05-25', '--fixture', FIXTURE, '--dry-run',
|
|
80
|
-
], { encoding: 'utf8' });
|
|
81
|
-
assert.equal(res.status, 0);
|
|
82
|
-
const r = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
83
|
-
assert.deepEqual(r.files_written, []);
|
|
84
|
-
let exists = true;
|
|
85
|
-
try { await fs.access(path.join(projectRoot2, 'Evidence', 'ushak', 'email')); } catch { exists = false; }
|
|
86
|
-
assert.equal(exists, false);
|
|
87
|
-
} finally {
|
|
88
|
-
await fs.rm(projectRoot2, { recursive: true, force: true });
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('missing --entity exits 2', () => {
|
|
93
|
-
const res = spawnSync(process.execPath, [RUNNER,
|
|
94
|
-
'--project', projectRoot, '--alias', 'ushak', '--fixture', FIXTURE,
|
|
95
|
-
], { encoding: 'utf8' });
|
|
96
|
-
assert.equal(res.status, 2);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test('v5.6.0: non-retryable error emits a learning candidate file', async () => {
|
|
100
|
-
const projectRoot3 = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-email-novel-'));
|
|
101
|
-
await fs.mkdir(path.join(projectRoot3, 'Evidence', 'ushak'), { recursive: true });
|
|
102
|
-
await fs.writeFile(path.join(projectRoot3, 'integrations.yml'), YAML.stringify({}));
|
|
103
|
-
const NOVEL_FIXTURE = path.join(HERE, '..', 'fixtures', 'email-novel-error.json');
|
|
104
|
-
try {
|
|
105
|
-
const res = spawnSync(process.execPath, [RUNNER,
|
|
106
|
-
'--project', projectRoot3, '--alias', 'ushak',
|
|
107
|
-
'--entity', '23. ABN AMRO', '--week', '2026-05-25', '--fixture', NOVEL_FIXTURE,
|
|
108
|
-
], { encoding: 'utf8' });
|
|
109
|
-
assert.equal(res.status, 0, `stderr: ${res.stderr}`);
|
|
110
|
-
const r = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
111
|
-
assert.equal(r.status, 'failed');
|
|
112
|
-
const candDir = path.join(projectRoot3, 'Evidence', '_learnings-candidates');
|
|
113
|
-
const entries = await fs.readdir(candDir);
|
|
114
|
-
const mdFiles = entries.filter(f => f.endsWith('.md'));
|
|
115
|
-
assert.equal(mdFiles.length, 1, `expected exactly one candidate, got: ${entries.join(', ')}`);
|
|
116
|
-
const body = await fs.readFile(path.join(candDir, mdFiles[0]), 'utf8');
|
|
117
|
-
assert.match(body, /fetch-failed/);
|
|
118
|
-
assert.match(body, /unexpected null/);
|
|
119
|
-
// Re-run within window — should not duplicate.
|
|
120
|
-
spawnSync(process.execPath, [RUNNER,
|
|
121
|
-
'--project', projectRoot3, '--alias', 'ushak',
|
|
122
|
-
'--entity', '23. ABN AMRO', '--week', '2026-05-25', '--fixture', NOVEL_FIXTURE,
|
|
123
|
-
], { encoding: 'utf8' });
|
|
124
|
-
const entries2 = await fs.readdir(candDir);
|
|
125
|
-
const mdFiles2 = entries2.filter(f => f.endsWith('.md'));
|
|
126
|
-
assert.equal(mdFiles2.length, 1, 'dedup should prevent a 2nd file within 7d');
|
|
127
|
-
} finally {
|
|
128
|
-
await fs.rm(projectRoot3, { recursive: true, force: true });
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('v5.6.0: user-side error does NOT emit a learning candidate', async () => {
|
|
133
|
-
const projectRoot4 = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-email-userside-'));
|
|
134
|
-
await fs.mkdir(path.join(projectRoot4, 'Evidence', 'ushak'), { recursive: true });
|
|
135
|
-
await fs.writeFile(path.join(projectRoot4, 'integrations.yml'), YAML.stringify({}));
|
|
136
|
-
try {
|
|
137
|
-
const res = spawnSync(process.execPath, [RUNNER,
|
|
138
|
-
'--project', projectRoot4, '--alias', 'ushak',
|
|
139
|
-
'--entity', 'NoSuchFolder', '--week', '2026-05-25', '--fixture', FIXTURE,
|
|
140
|
-
], { encoding: 'utf8' });
|
|
141
|
-
assert.equal(res.status, 0);
|
|
142
|
-
let exists = true;
|
|
143
|
-
try { await fs.access(path.join(projectRoot4, 'Evidence', '_learnings-candidates')); }
|
|
144
|
-
catch { exists = false; }
|
|
145
|
-
assert.equal(exists, false, 'folder-not-found should never trigger candidate emission');
|
|
146
|
-
} finally {
|
|
147
|
-
await fs.rm(projectRoot4, { recursive: true, force: true });
|
|
148
|
-
}
|
|
149
|
-
});
|