kushi-agents 6.0.0 → 6.0.1

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/bin/cli.mjs CHANGED
@@ -24,30 +24,21 @@ if (args.length > 0 && VERB_ALIASES[args[0]]) {
24
24
  args[0] = VERB_ALIASES[args[0]];
25
25
  }
26
26
 
27
- // ── alias resolution (v5.9.7+) ──────────────────────────────────────────────
28
- // Mirrors `instructions/identity-resolution.instructions.md`: read alias from
29
- // <cwd>/.kushi/config/user/project-evidence.yml, with KUSHI_ALIAS env override.
30
- // No silent git-email derivation, no Graph fallback — same rules as chat.
27
+ // ── alias resolution (v5.9.7+ / v6.0.1 deterministic auto) ─────────────────
28
+ // Mirrors `instructions/identity-resolution.instructions.md`. Precedence:
29
+ // 1. KUSHI_ALIAS env var (cron / CI override)
30
+ // 2. <cwd>/.kushi/config/user/project-evidence.yml -> alias: field
31
+ // (skipped if value is a placeholder like `<auto>` / `<...>` / `your-alias`)
32
+ // 3. m365-auth.json -> _meta.owner UPN — mailNickname before '@'.
33
+ // 4. os.userInfo().username — sanitized, mirrors seed-config.applyDerivedDefaults().
34
+ // 5. only if all of those fail → return null and print the 4-fix error.
35
+ //
36
+ // v6.0.0 incorrectly stopped at step 2 and treated `<auto>` as a hard error,
37
+ // which contradicted the project-evidence.yml comment that promises auto-detection.
38
+ // Implementation lives in src/resolve-alias.mjs so it's unit-testable.
31
39
  async function resolveAlias() {
32
- if (process.env.KUSHI_ALIAS && process.env.KUSHI_ALIAS.trim()) {
33
- return { alias: process.env.KUSHI_ALIAS.trim(), source: 'env' };
34
- }
35
- const fs = await import('node:fs');
36
- const path = await import('node:path');
37
- const cfgPath = path.resolve(process.cwd(), '.kushi', 'config', 'user', 'project-evidence.yml');
38
- if (!fs.existsSync(cfgPath)) return { alias: null, source: 'missing-config' };
39
- try {
40
- const txt = fs.readFileSync(cfgPath, 'utf-8');
41
- const m = txt.match(/^\s*alias:\s*([^\s#]+)/m);
42
- if (!m) return { alias: null, source: 'no-alias-key' };
43
- const v = m[1].trim();
44
- if (!v || v === '<auto>' || v.startsWith('<') || v === 'your-alias') {
45
- return { alias: null, source: 'placeholder' };
46
- }
47
- return { alias: v, source: 'project-evidence.yml' };
48
- } catch {
49
- return { alias: null, source: 'read-error' };
50
- }
40
+ const { resolveAliasFor } = await import('../src/resolve-alias.mjs');
41
+ return resolveAliasFor(process.cwd());
51
42
  }
52
43
 
53
44
  function printAliasError(verb, project) {
@@ -313,6 +304,8 @@ if (args.length > 0 && ['refresh', 'bootstrap', 'discover', 'references'].includ
313
304
  aliasArgs = ['--alias', alias];
314
305
  if (source === 'env') {
315
306
  process.stderr.write(`\n Using alias='${alias}' from KUSHI_ALIAS env var.\n`);
307
+ } else if (source === 'm365-auth-upn' || source === 'os-username') {
308
+ process.stderr.write(`\n Using alias='${alias}' (auto-derived from ${source === 'm365-auth-upn' ? 'm365-auth.json owner UPN' : 'OS username'}). Override via 'alias:' in .kushi/config/user/project-evidence.yml or KUSHI_ALIAS env var.\n`);
316
309
  }
317
310
  }
318
311
  const r = spawnSync(process.execPath, [runner, '--project', project, ...aliasArgs, ...passthrough], { stdio: 'inherit' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
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": {
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "license": "MIT",
46
46
  "scripts": {
47
- "test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs src/get-kushi-config.test.mjs src/seed-config-derived.test.mjs plugin/runners/test/unit/*.test.mjs",
47
+ "test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs src/get-kushi-config.test.mjs src/seed-config-derived.test.mjs src/resolve-alias.test.mjs plugin/runners/test/unit/*.test.mjs",
48
48
  "test:runners": "node --test plugin/runners/test/unit/*.test.mjs",
49
49
  "test:runners:integration": "node --test plugin/runners/test/integration/*.test.mjs",
50
50
  "test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
@@ -117,7 +117,19 @@ async function main() {
117
117
  const client = await buildClient({ mailbox: args.mailbox, fixture: args.fixture });
118
118
  const startedAt = new Date().toISOString();
119
119
 
120
- const folder = await client.findFolder(args.entity).catch(e => { throw e; });
120
+ let folder;
121
+ try {
122
+ folder = await client.findFolder(args.entity);
123
+ } catch (e) {
124
+ const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
125
+ const status = retryable ? 'deferred' : 'failed';
126
+ await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: status, last_error: `findFolder: ${e.message}` });
127
+ if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'find-folder-failed', reason: e.message });
128
+ if (!retryable && !args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'find-folder-failed', message: e.message, status: e.status }, context: { runner: 'pull-email' } });
129
+ if (!args.dryRun) await appendRunLog(projectRoot, { runner: 'pull-email', alias: args.alias, entity: args.entity, week: weekStart, status, error: e.message, http_status: e.status ?? null });
130
+ emit({ source: SOURCE, entity: args.entity, week: weekStart, status, errors: [{ signature: 'find-folder-failed', message: e.message, status: e.status }] });
131
+ return retryable ? 1 : 0;
132
+ }
121
133
  if (!folder) {
122
134
  await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
123
135
  last_status: 'failed', last_error: `folder not found: ${args.entity}`,
@@ -134,12 +146,14 @@ async function main() {
134
146
  await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
135
147
  if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
136
148
  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' } });
149
+ if (!args.dryRun) await appendRunLog(projectRoot, { runner: 'pull-email', alias: args.alias, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', error: e.message, http_status: e.status ?? null });
137
150
  emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
138
151
  return retryable ? 1 : 0;
139
152
  }
140
153
 
141
154
  if (messages.length === 0) {
142
155
  await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0, folder_id: folder.id, folder_name: folder.displayName });
156
+ if (!args.dryRun) await appendRunLog(projectRoot, { runner: 'pull-email', alias: args.alias, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0 });
143
157
  emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
144
158
  return 0;
145
159
  }
@@ -24,6 +24,7 @@ import { loadConfig, assertProject } from './lib/config.mjs';
24
24
  import { readLedger, needsPull } from './lib/ledger.mjs';
25
25
  import { currentIsoMonday, ymd } from './lib/weeks.mjs';
26
26
  import { readCandidateCount } from './lib/learnings.mjs';
27
+ import { writeRefreshReport, appendRunLog } from './lib/runlog.mjs';
27
28
 
28
29
  const HERE = path.dirname(fileURLToPath(import.meta.url));
29
30
 
@@ -237,6 +238,54 @@ async function main() {
237
238
 
238
239
  const learning_candidates_total = args.dryRun ? 0 : await readCandidateCount(args.project);
239
240
 
241
+ // v6.0.1: orchestrator-level diagnostics — write a refresh report and append
242
+ // run-log entries for EVERY result (captured / no-activity / partial /
243
+ // deferred / failed). Per-runner appendRunLog calls only fired on success
244
+ // before, so failures left no audit trail beyond the ephemeral stdout JSON.
245
+ const counts = { captured: 0, 'no-activity': 0, partial: 0, deferred: 0, failed: 0, other: 0 };
246
+ for (const r of results) {
247
+ const status = r?.parsed?.status || (r?.dry_run ? 'dry-run' : 'unknown');
248
+ if (counts[status] !== undefined) counts[status]++; else counts.other++;
249
+ }
250
+ if (!args.dryRun) {
251
+ try {
252
+ await writeRefreshReport(args.project, args.alias, {
253
+ type: args.mode,
254
+ summary: `${args.mode} ${weekStart}: planned=${planned.length} skipped=${skipped.length} captured=${counts.captured} no-activity=${counts['no-activity']} partial=${counts.partial} deferred=${counts.deferred} failed=${counts.failed}`,
255
+ details: {
256
+ week: weekStart,
257
+ mode: args.mode,
258
+ planned: planned.length,
259
+ skipped: skipped.length,
260
+ counts,
261
+ results: results.map(r => ({
262
+ source: r.source,
263
+ entity: r.entity,
264
+ status: r?.parsed?.status,
265
+ exit_code: r.exit_code,
266
+ errors: r?.parsed?.errors,
267
+ })),
268
+ },
269
+ });
270
+ } catch (e) { /* refresh-report is diagnostics-only, never block */ }
271
+
272
+ for (const r of results) {
273
+ const status = r?.parsed?.status;
274
+ if (!status || status === 'captured') continue; // captured already logged by per-runner
275
+ try {
276
+ await appendRunLog(args.project, {
277
+ runner: `pull-${r.source}`,
278
+ alias: args.alias,
279
+ entity: r.entity,
280
+ week: weekStart,
281
+ status,
282
+ via: 'refresh-orchestrator',
283
+ errors: r?.parsed?.errors,
284
+ });
285
+ } catch (e) { /* run-log is append-only diagnostics */ }
286
+ }
287
+ }
288
+
240
289
  emit({
241
290
  status: 'ok',
242
291
  project: args.project,
@@ -0,0 +1,60 @@
1
+ // src/resolve-alias.mjs
2
+ // Deterministic alias resolution for the CLI runner-dispatch path.
3
+ // Extracted from bin/cli.mjs so it's unit-testable.
4
+ //
5
+ // Precedence:
6
+ // 1. KUSHI_ALIAS env var (cron / CI override)
7
+ // 2. <cwd>/.kushi/config/user/project-evidence.yml -> alias: field
8
+ // (skipped if value is a placeholder like `<auto>` / `<...>` / `your-alias`)
9
+ // 3. <cwd>/.kushi/config/user/m365-auth.json -> _meta.owner UPN
10
+ // (mailNickname before '@'; sanitized)
11
+ // 4. os.userInfo().username — sanitized (mirrors seed-config.applyDerivedDefaults)
12
+ // 5. else null with source 'placeholder'
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import os from 'node:os';
17
+
18
+ function sanitize(raw) {
19
+ return String(raw || '').toLowerCase().replace(/[^a-z0-9._-]/g, '');
20
+ }
21
+
22
+ export function resolveAliasFor(cwd, env = process.env, userInfo = () => os.userInfo()) {
23
+ if (env.KUSHI_ALIAS && env.KUSHI_ALIAS.trim()) {
24
+ return { alias: env.KUSHI_ALIAS.trim(), source: 'env' };
25
+ }
26
+
27
+ const cfgPath = path.resolve(cwd, '.kushi', 'config', 'user', 'project-evidence.yml');
28
+ const cfgExists = fs.existsSync(cfgPath);
29
+
30
+ if (cfgExists) {
31
+ try {
32
+ const txt = fs.readFileSync(cfgPath, 'utf-8');
33
+ const m = txt.match(/^\s*alias:\s*([^\s#]+)/m);
34
+ if (m) {
35
+ const v = m[1].trim();
36
+ const isPlaceholder = !v || v === '<auto>' || v.startsWith('<') || v === 'your-alias';
37
+ if (!isPlaceholder) return { alias: v, source: 'project-evidence.yml' };
38
+ }
39
+ } catch { /* fall through */ }
40
+
41
+ const authPath = path.resolve(cwd, '.kushi', 'config', 'user', 'm365-auth.json');
42
+ if (fs.existsSync(authPath)) {
43
+ try {
44
+ const obj = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
45
+ const upn = obj && obj._meta && typeof obj._meta.owner === 'string' ? obj._meta.owner : '';
46
+ const local = sanitize(upn.split('@')[0]);
47
+ if (local && !local.startsWith('<')) {
48
+ return { alias: local, source: 'm365-auth-upn' };
49
+ }
50
+ } catch { /* fall through */ }
51
+ }
52
+ }
53
+
54
+ try {
55
+ const u = sanitize(userInfo().username);
56
+ if (u) return { alias: u, source: 'os-username' };
57
+ } catch { /* fall through */ }
58
+
59
+ return { alias: null, source: 'placeholder' };
60
+ }
@@ -0,0 +1,91 @@
1
+ // src/resolve-alias.test.mjs
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { resolveAliasFor } from './resolve-alias.mjs';
8
+
9
+ function tmp() {
10
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'kushi-alias-'));
11
+ return d;
12
+ }
13
+
14
+ function seedEvidenceYml(dir, value) {
15
+ const p = path.join(dir, '.kushi', 'config', 'user');
16
+ fs.mkdirSync(p, { recursive: true });
17
+ fs.writeFileSync(path.join(p, 'project-evidence.yml'), `alias: ${value}\nfoo: bar\n`);
18
+ }
19
+
20
+ function seedM365Auth(dir, owner) {
21
+ const p = path.join(dir, '.kushi', 'config', 'user');
22
+ fs.mkdirSync(p, { recursive: true });
23
+ fs.writeFileSync(path.join(p, 'm365-auth.json'), JSON.stringify({ _meta: { owner } }));
24
+ }
25
+
26
+ test('resolveAlias: env var wins over yml + m365-auth', () => {
27
+ const d = tmp();
28
+ seedEvidenceYml(d, 'fromyml');
29
+ seedM365Auth(d, 'fromupn@contoso.com');
30
+ const r = resolveAliasFor(d, { KUSHI_ALIAS: 'fromenv' }, () => ({ username: 'fromos' }));
31
+ assert.equal(r.source, 'env');
32
+ assert.equal(r.alias, 'fromenv');
33
+ });
34
+
35
+ test('resolveAlias: real value in project-evidence.yml is used', () => {
36
+ const d = tmp();
37
+ seedEvidenceYml(d, 'realalias');
38
+ const r = resolveAliasFor(d, {}, () => ({ username: 'fromos' }));
39
+ assert.equal(r.source, 'project-evidence.yml');
40
+ assert.equal(r.alias, 'realalias');
41
+ });
42
+
43
+ test('resolveAlias: <auto> placeholder falls through to m365-auth UPN', () => {
44
+ const d = tmp();
45
+ seedEvidenceYml(d, '<auto>');
46
+ seedM365Auth(d, 'jane.doe@microsoft.com');
47
+ const r = resolveAliasFor(d, {}, () => ({ username: 'shouldnotuse' }));
48
+ assert.equal(r.source, 'm365-auth-upn');
49
+ assert.equal(r.alias, 'jane.doe');
50
+ });
51
+
52
+ test('resolveAlias: <auto> + no m365-auth falls through to OS username', () => {
53
+ const d = tmp();
54
+ seedEvidenceYml(d, '<auto>');
55
+ const r = resolveAliasFor(d, {}, () => ({ username: 'OS_User' }));
56
+ assert.equal(r.source, 'os-username');
57
+ assert.equal(r.alias, 'os_user');
58
+ });
59
+
60
+ test('resolveAlias: <foo> generic placeholder also falls through', () => {
61
+ const d = tmp();
62
+ seedEvidenceYml(d, '<your-alias>');
63
+ const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
64
+ assert.equal(r.source, 'os-username');
65
+ assert.equal(r.alias, 'osuser');
66
+ });
67
+
68
+ test('resolveAlias: literal "your-alias" treated as placeholder', () => {
69
+ const d = tmp();
70
+ seedEvidenceYml(d, 'your-alias');
71
+ const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
72
+ assert.equal(r.source, 'os-username');
73
+ assert.equal(r.alias, 'osuser');
74
+ });
75
+
76
+ test('resolveAlias: missing .kushi/ falls through to OS username (deterministic last resort)', () => {
77
+ const d = tmp();
78
+ const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
79
+ assert.equal(r.source, 'os-username');
80
+ assert.equal(r.alias, 'osuser');
81
+ });
82
+
83
+ test('resolveAlias: m365-auth UPN sanitizes uppercase + special chars', () => {
84
+ const d = tmp();
85
+ seedEvidenceYml(d, '<auto>');
86
+ seedM365Auth(d, 'Bob+Tag@microsoft.com');
87
+ const r = resolveAliasFor(d, {}, () => ({ username: 'osuser' }));
88
+ assert.equal(r.source, 'm365-auth-upn');
89
+ // '+' is stripped, lowercased
90
+ assert.equal(r.alias, 'bobtag');
91
+ });