kushi-agents 6.1.0 → 6.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/instructions/evidence-layout-canonical.instructions.md +3 -3
- package/plugin/runners/bootstrap.mjs +60 -1
- package/plugin/runners/discover.mjs +54 -13
- package/plugin/runners/lib/csc-pull.mjs +3 -1
- package/plugin/runners/lib/layout.mjs +4 -4
- package/plugin/runners/lib/workiq.mjs +5 -2
- package/plugin/runners/test/integration/bootstrap.integration.test.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kushi-agents",
|
|
3
|
-
"version": "6.1.
|
|
3
|
+
"version": "6.1.2",
|
|
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": {
|
|
@@ -62,8 +62,8 @@ Concretely, a pull skill MUST NOT create or write to any of these (representativ
|
|
|
62
62
|
| `<project>/_Weekly Summaries/` | `<project>/Evidence/<alias>/<source>/weekly/` |
|
|
63
63
|
| `<project>/Meetings/` (at project root) | `<project>/Evidence/<alias>/meetings/` |
|
|
64
64
|
| `<project>/Teams/` (at project root) | `<project>/Evidence/<alias>/teams/` |
|
|
65
|
-
| `<project>/SharePoint/` (at project root) | `<project>/Evidence
|
|
66
|
-
| `<project>/CRM/`, `<project>/ADO/` (at project root) | `<project>/Evidence
|
|
65
|
+
| `<project>/SharePoint/` (at project root) | `<project>/Evidence/_shared/sharepoint/` (project-scoped; v6.1.2+) |
|
|
66
|
+
| `<project>/CRM/`, `<project>/ADO/` (at project root) | `<project>/Evidence/_shared/{crm,ado}/` |
|
|
67
67
|
| any `<project>/<source>-context/`, `<project>/<source>-summary/`, etc. | `<project>/Evidence/<alias>/<source>/weekly/` |
|
|
68
68
|
|
|
69
69
|
`State/`, `Reports/`, `integrations.yml` are the **only** top-level siblings of `Evidence/` that pull/refresh skills are allowed to leave alone (they are written by `build-state`, `aggregate-project`, and bootstrap respectively — not by pull-* skills).
|
|
@@ -83,7 +83,7 @@ If alias is ambiguous (legacy folder pre-dates multi-user), default to the curre
|
|
|
83
83
|
|
|
84
84
|
By contract, downstream skills walk:
|
|
85
85
|
|
|
86
|
-
- `Evidence/*/email/`, `Evidence/*/teams/`, `Evidence/*/meetings/`, `Evidence/*/onenote/`, `Evidence
|
|
86
|
+
- `Evidence/*/email/`, `Evidence/*/teams/`, `Evidence/*/meetings/`, `Evidence/*/onenote/`, `Evidence/_shared/sharepoint/`, `Evidence/_shared/crm/`, `Evidence/_shared/ado/`
|
|
87
87
|
|
|
88
88
|
Anything outside these paths is invisible to them — by design. The path IS the contract. There is no "also scan sibling folders" fallback.
|
|
89
89
|
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
aliasRoot, projectSharedFile, userFile, USER_FILES,
|
|
27
27
|
} from './lib/layout.mjs';
|
|
28
28
|
import { writeAtomic, pathExists } from './lib/evidence.mjs';
|
|
29
|
+
import { writeRefreshReport, appendRunLog } from './lib/runlog.mjs';
|
|
29
30
|
|
|
30
31
|
function parseArgs(argv) {
|
|
31
32
|
const args = { force: false, dryRun: false, lookbackDays: null, interactive: false };
|
|
@@ -254,7 +255,7 @@ const USER_DIRS = [
|
|
|
254
255
|
USER_FILES.discovery,
|
|
255
256
|
USER_FILES.deferredRetries,
|
|
256
257
|
USER_FILES.refreshReports,
|
|
257
|
-
'email', 'teams', 'meetings', 'onenote',
|
|
258
|
+
'email', 'teams', 'meetings', 'onenote',
|
|
258
259
|
'crm-notes', 'ado-notes',
|
|
259
260
|
];
|
|
260
261
|
|
|
@@ -327,6 +328,63 @@ async function main() {
|
|
|
327
328
|
});
|
|
328
329
|
}
|
|
329
330
|
|
|
331
|
+
const startedAt = new Date(); // capture when scaffold started (before report)
|
|
332
|
+
// v6.1.2: write bootstrap report to refresh-reports/<ts>_bootstrap.md per
|
|
333
|
+
// run-reports doctrine. Bootstrap is scaffold-only (no HTTP, no pulls), so
|
|
334
|
+
// the report enumerates files/dirs created vs. existed, plus optional
|
|
335
|
+
// dateFloor + interactive outcomes. Diagnostics-only; never blocks.
|
|
336
|
+
let reportPath = null;
|
|
337
|
+
if (!args.dryRun) {
|
|
338
|
+
try {
|
|
339
|
+
const created = log.created.map(p => path.relative(root, p) || '.');
|
|
340
|
+
const existed = log.existed.map(p => path.relative(root, p) || '.');
|
|
341
|
+
const summaryLine = `bootstrap ${args.alias}: created=${created.length} existed=${existed.length}` +
|
|
342
|
+
(dateFloorReport && dateFloorReport.fields?.length ? ` dateFloor=${dateFloorReport.dateFloor}` : '') +
|
|
343
|
+
(interactiveReport && interactiveReport.fields?.length ? ` interactive=${interactiveReport.fields.length}` : '');
|
|
344
|
+
const details = {
|
|
345
|
+
mode: 'bootstrap',
|
|
346
|
+
contributor: args.alias,
|
|
347
|
+
started: startedAt.toISOString(),
|
|
348
|
+
ended: new Date().toISOString(),
|
|
349
|
+
scope: 'scaffold-only (no HTTP, no pulls)',
|
|
350
|
+
what_was_done: {
|
|
351
|
+
configs_probed: ['integrations.yml', 'project-info.md', 'external-links.yml', 'contributors.yml'],
|
|
352
|
+
shared_dirs: SHARED_DIRS,
|
|
353
|
+
user_dirs: USER_DIRS,
|
|
354
|
+
per_user_files: ['boundaries.yml', 'external-links.local.yml', '_ledger.yml'],
|
|
355
|
+
counts: {
|
|
356
|
+
created: created.length,
|
|
357
|
+
existed: existed.length,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
created,
|
|
361
|
+
existed,
|
|
362
|
+
date_floor: dateFloorReport || null,
|
|
363
|
+
interactive: interactiveReport || null,
|
|
364
|
+
skips_and_gaps: [
|
|
365
|
+
'Bootstrap is scaffold-only — no source pulls performed.',
|
|
366
|
+
'Run `kushi discover <project>` next to populate boundaries.yml/integrations.yml.',
|
|
367
|
+
'Then `kushi refresh <project>` to capture per-source CSC weekly files.',
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
const r = await writeRefreshReport(args.project, args.alias, {
|
|
371
|
+
type: 'bootstrap',
|
|
372
|
+
summary: summaryLine,
|
|
373
|
+
details,
|
|
374
|
+
});
|
|
375
|
+
reportPath = r?.path || null;
|
|
376
|
+
try {
|
|
377
|
+
await appendRunLog(args.project, {
|
|
378
|
+
mode: 'bootstrap',
|
|
379
|
+
contributor: args.alias,
|
|
380
|
+
status: 'ok',
|
|
381
|
+
summary: summaryLine,
|
|
382
|
+
report: reportPath ? path.relative(root, reportPath) : null,
|
|
383
|
+
});
|
|
384
|
+
} catch { /* run-log is diagnostics-only */ }
|
|
385
|
+
} catch { /* bootstrap-report is diagnostics-only, never block */ }
|
|
386
|
+
}
|
|
387
|
+
|
|
330
388
|
emit({
|
|
331
389
|
status: 'ok',
|
|
332
390
|
project: root,
|
|
@@ -334,6 +392,7 @@ async function main() {
|
|
|
334
392
|
created: log.created.map(p => path.relative(root, p) || '.'),
|
|
335
393
|
existed: log.existed.map(p => path.relative(root, p) || '.'),
|
|
336
394
|
dry_run: args.dryRun,
|
|
395
|
+
...(reportPath ? { report: path.relative(root, reportPath) } : {}),
|
|
337
396
|
...(dateFloorReport ? { date_floor: dateFloorReport } : {}),
|
|
338
397
|
...(interactiveReport ? { interactive: interactiveReport } : {}),
|
|
339
398
|
});
|
|
@@ -66,8 +66,8 @@ function buildPrompt(source, projectName, scope = null) {
|
|
|
66
66
|
meetings: `Find recurring Teams meeting series related to project "${projectName}".`,
|
|
67
67
|
onenote: `Find OneNote sections related to project "${projectName}".`,
|
|
68
68
|
sharepoint: `Find SharePoint sites or libraries related to project "${projectName}".`,
|
|
69
|
-
crm: `
|
|
70
|
-
ado: `
|
|
69
|
+
crm: `Search the user's indexed M365 content (emails, OneNote pages, SharePoint/OneDrive documents, Teams messages) for Dataverse CRM request/incident identifiers that relate to project "${projectName}". CRM IDs typically look like FE-2026-001458, REQ-12345, INC-9, or 6+ digit numerics, and appear in email signatures/subjects, project briefs, status decks, or Loop pages from iscrm.crm.dynamics.com. Return the request_id (preferred) or incident_number you see most consistently associated with the project.`,
|
|
70
|
+
ado: `Search the user's indexed M365 content (emails, OneNote pages, SharePoint/OneDrive documents, Teams messages) for Azure DevOps work items related to project "${projectName}" in the IndustrySolutions/IS Engagements org. Engagement work items are typically referenced by 5–7 digit numeric IDs in URLs like dev.azure.com/IndustrySolutions/IS%20Engagements/_workitems/edit/123456 or in shorthand like "AB#123456" / "WI 123456". Return the engagement_id (top-level "Engagement" work item id, preferred) or work_item_id you see most consistently associated with the project.`,
|
|
71
71
|
};
|
|
72
72
|
const fields = {
|
|
73
73
|
email: 'value (full folder path like "Inbox/Northwind"), confidence (high|medium|low)',
|
|
@@ -162,7 +162,7 @@ function isPlaceholder(v) {
|
|
|
162
162
|
if (!s) return true;
|
|
163
163
|
if (/^<.*>$/.test(s)) return true; // <value>, <chat_id>, etc.
|
|
164
164
|
if (/^turn\d+search\d+$/i.test(s)) return true; // WorkIQ web citation tokens
|
|
165
|
-
if (/^(unknown|n\/a|none|null|tbd|todo)$/i.test(s)) return true;
|
|
165
|
+
if (/^(unknown|n\/a|none|null|tbd|todo|not\s+explicitly\s+(available|provided|specified)|not\s+(available|provided|specified|applicable)|undisclosed)$/i.test(s)) return true;
|
|
166
166
|
return false;
|
|
167
167
|
}
|
|
168
168
|
|
|
@@ -178,19 +178,26 @@ function isValidValueFor(source, field, raw) {
|
|
|
178
178
|
return true;
|
|
179
179
|
}
|
|
180
180
|
if (source === 'teams' && field === 'chat_id') {
|
|
181
|
-
// Graph chat IDs
|
|
182
|
-
|
|
181
|
+
// Accept Graph chat IDs ('@thread' / long opaque) OR human-readable
|
|
182
|
+
// chat topics — WorkIQ frequently returns the topic when the Graph ID
|
|
183
|
+
// is unavailable. Refresh stage will resolve to a real chat.
|
|
184
|
+
return v.length >= 2 && v.length <= 200;
|
|
183
185
|
}
|
|
184
186
|
if (source === 'meetings' && field === 'join_url') {
|
|
185
|
-
|
|
187
|
+
// Prefer real Teams meetup URLs but also accept any https URL or
|
|
188
|
+
// a meeting subject — refresh stage will resolve.
|
|
189
|
+
if (v.startsWith('http')) return true;
|
|
190
|
+
return v.length >= 3 && v.length <= 200;
|
|
186
191
|
}
|
|
187
192
|
if (source === 'onenote' && field === 'section_file_id') {
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
return
|
|
193
|
+
// Accept hex section IDs OR section/page names. WorkIQ often only has
|
|
194
|
+
// the human-readable name; refresh resolves to the file ID.
|
|
195
|
+
return v.length >= 2 && v.length <= 200;
|
|
191
196
|
}
|
|
192
197
|
if (source === 'sharepoint' && field === 'site_url') {
|
|
193
|
-
|
|
198
|
+
// Prefer real SharePoint URLs; also accept site names/relative paths.
|
|
199
|
+
if (v.startsWith('http')) return v.includes('.sharepoint.com') || v.includes('/sites/');
|
|
200
|
+
return v.length >= 2 && v.length <= 300;
|
|
194
201
|
}
|
|
195
202
|
if (source === 'crm' && (field === 'request_id' || field === 'incident_number')) {
|
|
196
203
|
// CRM IDs vary: FE-2026-001458, REQ-12345, INC-9, plain numerics. Reject
|
|
@@ -228,7 +235,16 @@ function applyRows(source, rows, currentBounds, currentInteg) {
|
|
|
228
235
|
}
|
|
229
236
|
if (source === 'teams') {
|
|
230
237
|
const existing = currentBounds.teams?.chats || [];
|
|
231
|
-
|
|
238
|
+
// WorkIQ rarely has Graph chat IDs — citation tokens like "turn1search1"
|
|
239
|
+
// are common. Prefer chat_id; fall back to topic so refresh has a usable
|
|
240
|
+
// boundary descriptor instead of nothing.
|
|
241
|
+
const incoming = rows.map(r => {
|
|
242
|
+
const id = r.chat_id;
|
|
243
|
+
if (id && !isPlaceholder(id) && isValidValueFor('teams', 'chat_id', id)) return id;
|
|
244
|
+
const topic = r.topic;
|
|
245
|
+
if (topic && !isPlaceholder(topic)) return topic;
|
|
246
|
+
return null;
|
|
247
|
+
}).filter(Boolean);
|
|
232
248
|
const merged = dedup([...existing, ...incoming]);
|
|
233
249
|
const added = merged.filter(v => !existing.includes(v));
|
|
234
250
|
if (added.length) accepted.push(...added);
|
|
@@ -236,7 +252,13 @@ function applyRows(source, rows, currentBounds, currentInteg) {
|
|
|
236
252
|
}
|
|
237
253
|
if (source === 'meetings') {
|
|
238
254
|
const existing = currentBounds.meetings?.joinUrls || [];
|
|
239
|
-
const incoming = rows.map(r =>
|
|
255
|
+
const incoming = rows.map(r => {
|
|
256
|
+
const url = r.join_url;
|
|
257
|
+
if (url && !isPlaceholder(url) && isValidValueFor('meetings', 'join_url', url) && url.startsWith('http')) return url;
|
|
258
|
+
const subj = r.subject;
|
|
259
|
+
if (subj && !isPlaceholder(subj)) return subj;
|
|
260
|
+
return null;
|
|
261
|
+
}).filter(Boolean);
|
|
240
262
|
const merged = dedup([...existing, ...incoming]);
|
|
241
263
|
const added = merged.filter(v => !existing.includes(v));
|
|
242
264
|
if (added.length) accepted.push(...added);
|
|
@@ -244,7 +266,16 @@ function applyRows(source, rows, currentBounds, currentInteg) {
|
|
|
244
266
|
}
|
|
245
267
|
if (source === 'onenote') {
|
|
246
268
|
const existing = currentBounds.onenote?.section_file_ids || [];
|
|
247
|
-
|
|
269
|
+
// WorkIQ rarely has section_file_id (Graph property); usually returns
|
|
270
|
+
// citation tokens. Prefer real hex IDs; fall back to section_name so
|
|
271
|
+
// refresh has a usable boundary descriptor.
|
|
272
|
+
const incoming = rows.map(r => {
|
|
273
|
+
const id = r.section_file_id;
|
|
274
|
+
if (id && !isPlaceholder(id) && /^[0-9a-f][0-9a-f\-]{20,}$/i.test(String(id).trim())) return id;
|
|
275
|
+
const name = r.section_name;
|
|
276
|
+
if (name && !isPlaceholder(name)) return name;
|
|
277
|
+
return null;
|
|
278
|
+
}).filter(Boolean);
|
|
248
279
|
const merged = dedup([...existing, ...incoming]);
|
|
249
280
|
const added = merged.filter(v => !existing.includes(v));
|
|
250
281
|
if (added.length) accepted.push(...added);
|
|
@@ -391,6 +422,16 @@ async function main() {
|
|
|
391
422
|
rows = rowsFromBlocks(blocks, source);
|
|
392
423
|
const elapsed = Date.now() - t0;
|
|
393
424
|
const bytes = Buffer.byteLength(workiqStdout || '', 'utf8');
|
|
425
|
+
// Persist raw WorkIQ output for diagnosis. Lets users inspect why a
|
|
426
|
+
// source returned 0 accepted rows without re-running the query.
|
|
427
|
+
if (!args.dryRun) {
|
|
428
|
+
try {
|
|
429
|
+
const discoveryDir = path.join(aliasRoot(args.project, args.alias), '_discovery');
|
|
430
|
+
await fs.mkdir(discoveryDir, { recursive: true });
|
|
431
|
+
const header = `# discover ${source} @ ${new Date().toISOString()}\n# elapsed=${elapsed}ms bytes=${bytes} blocks=${blocks.length} rows=${rows.length}\n# prompt:\n${prompt.split('\n').map(l => '# ' + l).join('\n')}\n# --- workiq stdout ---\n`;
|
|
432
|
+
await fs.writeFile(path.join(discoveryDir, `${source}-raw.txt`), header + (workiqStdout || ''), 'utf8');
|
|
433
|
+
} catch { /* best-effort */ }
|
|
434
|
+
}
|
|
394
435
|
if (rows.length === 0 && bytes < 8) {
|
|
395
436
|
// Distinguish "workiq returned empty" from "timeout" — both used to look the same.
|
|
396
437
|
skipReason = 'workiq-empty-response';
|
|
@@ -69,8 +69,10 @@ export function buildPullPrompt({ source, project, entity, weekStart, scope, opt
|
|
|
69
69
|
if (scope?.notebookName) lines.push(`Restrict to notebook "${scope.notebookName}".`);
|
|
70
70
|
lines.push('One CSC block per page touched in the week.');
|
|
71
71
|
} else if (source === 'sharepoint') {
|
|
72
|
-
lines.push(`Find SharePoint files within site "${entity}" modified between ${fromYmd} and ${toYmd}, inclusive.`);
|
|
72
|
+
lines.push(`Find SharePoint files within site "${entity}" — including its top-level document libraries and the first level of folders inside each library (depth 1) — modified between ${fromYmd} and ${toYmd}, inclusive.`);
|
|
73
|
+
lines.push('For each file, capture: filename, full server-relative path, last_modified timestamp, last_modified_by, and any external links (http/https URLs to other systems, e.g. external SharePoint sites, ADO/CRM/Jira, Loop, OneDrive, third-party docs) referenced in the document body or metadata.');
|
|
73
74
|
lines.push('One CSC block per file touched in the week.');
|
|
75
|
+
lines.push('In the CSC block, place the captured external links into `topics` as a comma-separated list of bare URLs (or `_none_` if none), and use `summary` for a one-line description of what changed in the file.');
|
|
74
76
|
} else {
|
|
75
77
|
throw new Error(`csc-pull: unsupported source "${source}"`);
|
|
76
78
|
}
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
/** Sources whose record bodies are project-scoped (live under Evidence/_shared/). */
|
|
8
|
-
export const SHARED_SOURCES = new Set(['crm', 'ado']);
|
|
8
|
+
export const SHARED_SOURCES = new Set(['crm', 'ado', 'sharepoint']);
|
|
9
9
|
|
|
10
10
|
/** Sources whose captures are per-user (live under Evidence/<alias>/<source>/). */
|
|
11
|
-
export const USER_SOURCES = new Set(['email', 'teams', 'meetings', 'onenote'
|
|
11
|
+
export const USER_SOURCES = new Set(['email', 'teams', 'meetings', 'onenote']);
|
|
12
12
|
|
|
13
13
|
/** Per-user note folders for shared sources (annotations only, not record bodies). */
|
|
14
14
|
export const USER_NOTE_DIRS = { crm: 'crm-notes', ado: 'ado-notes' };
|
|
@@ -35,8 +35,8 @@ export function sharedRoot(project) {
|
|
|
35
35
|
|
|
36
36
|
/** Evidence/_shared/<source>/ — only for SHARED_SOURCES. */
|
|
37
37
|
export function sharedSourceDir(project, source) {
|
|
38
|
-
if (!SHARED_SOURCES.has(source)
|
|
39
|
-
throw new Error(`layout: source "${source}" is not a shared source (allowed: ${[...SHARED_SOURCES
|
|
38
|
+
if (!SHARED_SOURCES.has(source)) {
|
|
39
|
+
throw new Error(`layout: source "${source}" is not a shared source (allowed: ${[...SHARED_SOURCES].join(', ')})`);
|
|
40
40
|
}
|
|
41
41
|
return path.join(sharedRoot(project), source);
|
|
42
42
|
}
|
|
@@ -89,13 +89,16 @@ export async function ask(prompt, { bin, timeoutMs = 120_000, env = process.env,
|
|
|
89
89
|
export function parseCscBlocks(text) {
|
|
90
90
|
if (!text || typeof text !== 'string') return [];
|
|
91
91
|
const blocks = [];
|
|
92
|
-
|
|
92
|
+
// Accept either `>` (blockquote) or `|` (table-row style) line prefixes —
|
|
93
|
+
// WorkIQ emits both depending on rendering. Trailing two-space markdown line
|
|
94
|
+
// breaks are tolerated by the per-line strip.
|
|
95
|
+
const blockquoteRe = /(^|\n)[>|]\s*\[block:\s*([a-zA-Z0-9_.-]+)\]\s*\n((?:[>|]\s*[^\n]*\n?)+)/g;
|
|
93
96
|
let m;
|
|
94
97
|
while ((m = blockquoteRe.exec(text)) !== null) {
|
|
95
98
|
const name = m[2];
|
|
96
99
|
const body = m[3]
|
|
97
100
|
.split('\n')
|
|
98
|
-
.map(l => l.replace(
|
|
101
|
+
.map(l => l.replace(/^[>|]\s?/, '').replace(/\s+$/, ''))
|
|
99
102
|
.filter(l => l.length > 0)
|
|
100
103
|
.join('\n');
|
|
101
104
|
blocks.push({ name, raw: body, fields: parseKvLines(body) });
|
|
@@ -45,7 +45,7 @@ test('scaffolds project skeleton on empty dir', async () => {
|
|
|
45
45
|
const ledger = YAML.parse(await fs.readFile(path.join(p, 'Evidence', 'ushak', '_ledger.yml'), 'utf8'));
|
|
46
46
|
assert.deepEqual(ledger, { entries: {} });
|
|
47
47
|
for (const d of ['_discovery', '_deferred-retries', 'refresh-reports',
|
|
48
|
-
'email', 'teams', 'meetings', 'onenote',
|
|
48
|
+
'email', 'teams', 'meetings', 'onenote',
|
|
49
49
|
'crm-notes', 'ado-notes']) {
|
|
50
50
|
await fs.access(path.join(p, 'Evidence', 'ushak', d));
|
|
51
51
|
}
|