@moda-ai/cli 1.18.1 → 1.19.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/dist/cli.js CHANGED
@@ -250,6 +250,10 @@ Init flags:
250
250
  --no-ci-cd Skip CI/CD workflow creation
251
251
  --autosync Create GitHub Actions workflow to sync prompts + skills to Moda on push to main
252
252
  --no-autosync Skip autosync workflow creation
253
+ --harness-rescan Create GitHub Actions workflow to re-scan the harness and sync it to Moda on push to main
254
+ --no-harness-rescan Skip harness rescan workflow creation
255
+ --harness-rescan-paths=GLOBS Comma-separated path globs that scope which pushes trigger the
256
+ harness rescan workflow (implies --harness-rescan)
253
257
  --offline Run final validation offline (skip the online telemetry check)
254
258
 
255
259
  Provision flags (headless; requires an existing "moda auth login" session):
@@ -292,6 +296,7 @@ Examples:
292
296
  moda harness explain --agent=agent_support
293
297
  moda init --ci-cd
294
298
  moda init --autosync
299
+ moda init --harness-rescan --harness-rescan-paths='src/agents/**'
295
300
  moda overview --days-back=30
296
301
  moda clusters --time-range=7d
297
302
  moda conversations --search="error" --limit=5
@@ -707,7 +712,7 @@ export const commandRegistry = createCommandRegistry([
707
712
  {
708
713
  name: 'init',
709
714
  description: 'Set up Moda in your project',
710
- examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd', 'moda init --autosync'],
715
+ examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd', 'moda init --autosync', 'moda init --harness-rescan'],
711
716
  auth: 'none',
712
717
  outputModes: ['human', 'json', 'agent'],
713
718
  mutability: 'write',
package/dist/config.d.ts CHANGED
@@ -28,6 +28,18 @@ export declare function saveCliConfig(update: CliConfig, options?: ProfileIOOpti
28
28
  * long-lived process (or a test) sees the current value.
29
29
  */
30
30
  export declare function resolveApiKey(options?: ProfileIOOptions): string | undefined;
31
+ /**
32
+ * Signed CLI keys embed their tenant: the format is
33
+ * moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
34
+ * createApiKey/parseAndVerifySignedKey in
35
+ * apps/moda-backend/src/api-keys/api-keys.service.ts. The server derives the
36
+ * upload destination tenant from the key alone, so this is the only
37
+ * client-side tenant source that matches where data actually lands
38
+ * (config / MODA_TENANT_ID can disagree with a shadowing env key). The HMAC
39
+ * signature can only be verified server-side; treat the result as
40
+ * informational.
41
+ */
42
+ export declare function embeddedTenantIdFromApiKey(key: string): string | undefined;
31
43
  /**
32
44
  * Resolve the ingest worker base URL used by `moda workspace snapshot`.
33
45
  * Env override -> active profile -> legacy config -> production default.
package/dist/config.js CHANGED
@@ -40,6 +40,34 @@ export function resolveApiKey(options = {}) {
40
40
  loadCliConfig(options).api_key ||
41
41
  undefined);
42
42
  }
43
+ const SIGNED_API_KEY_PREFIX = 'moda_sk_';
44
+ const SIGNED_API_KEY_VERSION = 'v1';
45
+ /**
46
+ * Signed CLI keys embed their tenant: the format is
47
+ * moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
48
+ * createApiKey/parseAndVerifySignedKey in
49
+ * apps/moda-backend/src/api-keys/api-keys.service.ts. The server derives the
50
+ * upload destination tenant from the key alone, so this is the only
51
+ * client-side tenant source that matches where data actually lands
52
+ * (config / MODA_TENANT_ID can disagree with a shadowing env key). The HMAC
53
+ * signature can only be verified server-side; treat the result as
54
+ * informational.
55
+ */
56
+ export function embeddedTenantIdFromApiKey(key) {
57
+ if (!key.startsWith(SIGNED_API_KEY_PREFIX))
58
+ return undefined;
59
+ try {
60
+ const decoded = Buffer.from(key.slice(SIGNED_API_KEY_PREFIX.length), 'base64url').toString('utf8');
61
+ // Expected format: version:keyId:tenantId:timestamp:signature
62
+ const parts = decoded.split(':');
63
+ if (parts.length !== 5 || parts[0] !== SIGNED_API_KEY_VERSION)
64
+ return undefined;
65
+ return parts[2] || undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
43
71
  /**
44
72
  * Resolve the ingest worker base URL used by `moda workspace snapshot`.
45
73
  * Env override -> active profile -> legacy config -> production default.
package/dist/harness.d.ts CHANGED
@@ -390,6 +390,51 @@ declare function buildCommandScanPlan(context: CommandContext, rootDir: string):
390
390
  };
391
391
  export declare function writeHumanProgress(context: CommandContext, message: string): void;
392
392
  export declare function readHarnessSyncState(rootDir: string): HarnessSyncState | undefined;
393
+ /**
394
+ * The identity a sync-style upload will actually authenticate as, resolved
395
+ * with the same precedence as the API call itself (MODA_API_KEY env wins over
396
+ * the active profile / legacy config). Used for the pre-upload
397
+ * "Syncing as <credential> → tenant <id>" banner so a shadowed or stale key
398
+ * is visible BEFORE data lands in the wrong tenant.
399
+ *
400
+ * The destination tenant is derived from the WINNING KEY itself (signed CLI
401
+ * keys embed their tenant, and the server resolves the destination from the
402
+ * key alone) — never from flag/env/profile config, which is exactly what a
403
+ * shadowing stale key disagrees with. When the key does not embed a tenant,
404
+ * the config-resolved id is returned with `tenantSource: 'config'` so
405
+ * callers must label it unverified. Best-effort: never throws (a broken
406
+ * MODA_PROFILE must not abort onboarding).
407
+ */
408
+ export declare function describeSyncIdentity(options?: {
409
+ profile?: string;
410
+ env?: NodeJS.ProcessEnv;
411
+ }): {
412
+ credential: string;
413
+ tenantId?: string;
414
+ /** 'api-key' = decoded from the winning key (authoritative); 'config' = flag/env/profile fallback (unverified). */
415
+ tenantSource?: 'api-key' | 'config';
416
+ } | undefined;
417
+ /**
418
+ * Human tenant label for the identity banner: key-derived ids are printed
419
+ * bare; config-derived ids are explicitly marked unverified so the banner
420
+ * never asserts a destination the server might not use.
421
+ */
422
+ export declare function describeSyncTenantLabel(identity: {
423
+ tenantId?: string;
424
+ tenantSource?: 'api-key' | 'config';
425
+ }): string;
426
+ /**
427
+ * Reports in subdirectories that are FRESHER than the root's
428
+ * `.moda/harness-report.json` (a missing root report counts as infinitely
429
+ * old). Catches the `--path` trap: `harness analyze --path=<subdir>` writes
430
+ * `<subdir>/.moda/harness-report.json`, and a later root-level
431
+ * `harness approve`/`sync` silently targets the stale (or absent) root report.
432
+ * Newest first.
433
+ */
434
+ export declare function findFresherHarnessReports(rootDir: string): Array<{
435
+ path: string;
436
+ mtimeMs: number;
437
+ }>;
393
438
  export declare function writeHarnessArtifacts(graph: HarnessGraph, rootDir: string, options?: {
394
439
  summary?: boolean;
395
440
  }): string[];
package/dist/harness.js CHANGED
@@ -6,7 +6,7 @@ import { basename, delimiter, dirname, extname, join, relative, resolve, sep } f
6
6
  import { confirm, select } from '@inquirer/prompts';
7
7
  import { callControlAPI } from './api-client.js';
8
8
  import { AnalystTui, shouldUseAnalystTui } from './analyst-tui.js';
9
- import { resolveApiKey, resolveTenantId } from './config.js';
9
+ import { embeddedTenantIdFromApiKey, resolveApiKey, resolveTenantId } from './config.js';
10
10
  import { CliInputError, CliInputRequiredError } from './errors.js';
11
11
  import { HARNESS_REMOTE_RUN_STATE_PATH, isRemoteHarnessAnalyze, readRemoteRunState, runHarnessPull, runRemoteHarnessAnalyst, } from './harness-remote.js';
12
12
  import { getActiveProfile } from './profiles.js';
@@ -101,6 +101,24 @@ export async function runHarnessCommand(context) {
101
101
  if (!resolveApiKey({ profile: context.profile, env: context.env }) && !context.dryRun) {
102
102
  throw new CliInputError('No Moda API key found for harness sync.', 'Run `moda init` first, set MODA_API_KEY, or select a profile with an API key.');
103
103
  }
104
+ warnFresherSubdirHarnessReports(context, rootDir, context.flags['from-report'] ?? context.flags.report);
105
+ if (!context.dryRun) {
106
+ const identity = describeSyncIdentity({ profile: context.profile, env: context.env });
107
+ if (identity) {
108
+ // The banner's tenant comes from the winning key when it embeds
109
+ // one. Otherwise fall back to the flag/env/profile-resolved tenant
110
+ // but label it unverified — that source is precisely what a
111
+ // shadowing stale key disagrees with, so it must never be asserted
112
+ // as the destination.
113
+ const label = identity.tenantSource === 'api-key'
114
+ ? describeSyncTenantLabel(identity)
115
+ : describeSyncTenantLabel({
116
+ tenantId: tenant?.tenantId ?? identity.tenantId,
117
+ tenantSource: 'config',
118
+ });
119
+ writeHumanProgress(context, `Syncing as ${identity.credential} → tenant ${label}`);
120
+ }
121
+ }
104
122
  if (context.flags['from-report'] || context.flags.report) {
105
123
  const reportPath = resolveHarnessReportPath(rootDir, context.flags['from-report'] ?? context.flags.report);
106
124
  const { report, validation, approval, approved } = loadHarnessReportForSync(rootDir, reportPath);
@@ -344,6 +362,7 @@ export async function runHarnessCommand(context) {
344
362
  return { exitCode: validation.status === 'pass' ? 0 : 1 };
345
363
  }
346
364
  case 'approve': {
365
+ warnFresherSubdirHarnessReports(context, rootDir, context.positionals[1] ?? context.flags.report);
347
366
  const reportPath = resolveHarnessReportPath(rootDir, context.positionals[1] ?? context.flags.report);
348
367
  const approval = await approveHarnessReport({
349
368
  rootDir,
@@ -363,7 +382,7 @@ export async function runHarnessCommand(context) {
363
382
  return { exitCode: 0 };
364
383
  }
365
384
  case 'agents': {
366
- const graph = loadOrScanHarness(rootDir, tenant);
385
+ const graph = loadOrScanHarness(rootDir, tenant, context);
367
386
  context.output.writeData({
368
387
  summary: summarizeHarness(graph),
369
388
  agentFamilies: graph.agentFamilies,
@@ -373,12 +392,12 @@ export async function runHarnessCommand(context) {
373
392
  return { exitCode: 0 };
374
393
  }
375
394
  case 'graph': {
376
- const graph = loadOrScanHarness(rootDir, tenant);
395
+ const graph = loadOrScanHarness(rootDir, tenant, context);
377
396
  context.output.writeData(graph);
378
397
  return { exitCode: 0 };
379
398
  }
380
399
  case 'explain': {
381
- const graph = loadOrScanHarness(rootDir, tenant);
400
+ const graph = loadOrScanHarness(rootDir, tenant, context);
382
401
  if (selectedAgentId) {
383
402
  const agent = graph.runtimeAgents.find((candidate) => candidate.id === selectedAgentId);
384
403
  if (!agent) {
@@ -2040,15 +2059,187 @@ function selectedRuntimeAgentId(flags) {
2040
2059
  return flags.agent;
2041
2060
  return undefined;
2042
2061
  }
2043
- function loadOrScanHarness(rootDir, tenant) {
2062
+ function loadOrScanHarness(rootDir, tenant, context) {
2044
2063
  const manifestPath = join(rootDir, HARNESS_PATH);
2045
- return existsSync(manifestPath)
2046
- ? readHarnessGraph(manifestPath)
2047
- : scanHarness({
2048
- rootDir,
2049
- tenantId: tenant?.tenantId,
2050
- tenantSource: tenant?.source,
2051
- });
2064
+ if (existsSync(manifestPath))
2065
+ return readHarnessGraph(manifestPath);
2066
+ // Provenance banner: without a persisted manifest this command output comes
2067
+ // from a live scan of the current directory, which is easy to mistake for
2068
+ // synced/account data (especially right after switching repos or profiles).
2069
+ if (context) {
2070
+ writeHarnessNotice(context, `No ${HARNESS_PATH} found; showing a live scan of ${rootDir}. ` +
2071
+ 'This is local scan output, NOT synced or account data. ' +
2072
+ 'Run `moda harness scan` to persist it and `moda harness sync` to upload it.');
2073
+ }
2074
+ return scanHarness({
2075
+ rootDir,
2076
+ tenantId: tenant?.tenantId,
2077
+ tenantSource: tenant?.source,
2078
+ });
2079
+ }
2080
+ /**
2081
+ * Warning line that must be visible in every output mode: styled progress on
2082
+ * stderr for humans, a plain `Warning:` stderr line for machine modes (stdout
2083
+ * stays machine-readable JSON either way). Quiet suppresses it like the rest
2084
+ * of the harness progress surface.
2085
+ */
2086
+ function writeHarnessNotice(context, message) {
2087
+ if (context.quiet && context.flags.progress !== 'true')
2088
+ return;
2089
+ if (context.outputMode === 'human') {
2090
+ console.error(renderHarnessProgress(message, 'warning'));
2091
+ return;
2092
+ }
2093
+ console.error(`Warning: ${message}`);
2094
+ }
2095
+ /**
2096
+ * The identity a sync-style upload will actually authenticate as, resolved
2097
+ * with the same precedence as the API call itself (MODA_API_KEY env wins over
2098
+ * the active profile / legacy config). Used for the pre-upload
2099
+ * "Syncing as <credential> → tenant <id>" banner so a shadowed or stale key
2100
+ * is visible BEFORE data lands in the wrong tenant.
2101
+ *
2102
+ * The destination tenant is derived from the WINNING KEY itself (signed CLI
2103
+ * keys embed their tenant, and the server resolves the destination from the
2104
+ * key alone) — never from flag/env/profile config, which is exactly what a
2105
+ * shadowing stale key disagrees with. When the key does not embed a tenant,
2106
+ * the config-resolved id is returned with `tenantSource: 'config'` so
2107
+ * callers must label it unverified. Best-effort: never throws (a broken
2108
+ * MODA_PROFILE must not abort onboarding).
2109
+ */
2110
+ export function describeSyncIdentity(options = {}) {
2111
+ const apiKey = resolveApiKey(options);
2112
+ if (!apiKey)
2113
+ return undefined;
2114
+ const redacted = apiKey.length > 12
2115
+ ? `${apiKey.slice(0, 8)}…${apiKey.slice(-4)}`
2116
+ : `…${apiKey.slice(-4)}`;
2117
+ const env = options.env ?? process.env;
2118
+ // getActiveProfile throws when the named profile is missing (e.g. a stale
2119
+ // MODA_PROFILE) even though the key already resolved via legacy config —
2120
+ // the banner is advisory and must never abort the command around it.
2121
+ let profileName;
2122
+ try {
2123
+ profileName = getActiveProfile(options).name;
2124
+ }
2125
+ catch {
2126
+ profileName = undefined;
2127
+ }
2128
+ const credential = env.MODA_API_KEY === apiKey
2129
+ ? `MODA_API_KEY env key (${redacted})`
2130
+ : profileName
2131
+ ? `profile '${profileName}' key (${redacted})`
2132
+ : `stored key (${redacted})`;
2133
+ const keyTenantId = embeddedTenantIdFromApiKey(apiKey);
2134
+ if (keyTenantId) {
2135
+ return { credential, tenantId: keyTenantId, tenantSource: 'api-key' };
2136
+ }
2137
+ const configTenantId = resolveTenantId(options);
2138
+ return {
2139
+ credential,
2140
+ tenantId: configTenantId,
2141
+ tenantSource: configTenantId ? 'config' : undefined,
2142
+ };
2143
+ }
2144
+ /**
2145
+ * Human tenant label for the identity banner: key-derived ids are printed
2146
+ * bare; config-derived ids are explicitly marked unverified so the banner
2147
+ * never asserts a destination the server might not use.
2148
+ */
2149
+ export function describeSyncTenantLabel(identity) {
2150
+ if (!identity.tenantId)
2151
+ return 'unresolved';
2152
+ if (identity.tenantSource === 'api-key')
2153
+ return identity.tenantId;
2154
+ return `${identity.tenantId} (unverified — from local config, not the API key; the server derives the destination tenant from the key)`;
2155
+ }
2156
+ /** Bounded walk looking for `<subdir>/.moda/harness-report.json` files. */
2157
+ const FRESHER_REPORT_MAX_DEPTH = 4;
2158
+ const FRESHER_REPORT_MAX_DIRS = 4_000;
2159
+ /**
2160
+ * Reports in subdirectories that are FRESHER than the root's
2161
+ * `.moda/harness-report.json` (a missing root report counts as infinitely
2162
+ * old). Catches the `--path` trap: `harness analyze --path=<subdir>` writes
2163
+ * `<subdir>/.moda/harness-report.json`, and a later root-level
2164
+ * `harness approve`/`sync` silently targets the stale (or absent) root report.
2165
+ * Newest first.
2166
+ */
2167
+ export function findFresherHarnessReports(rootDir) {
2168
+ let rootMtimeMs = 0;
2169
+ try {
2170
+ rootMtimeMs = lstatSync(join(rootDir, HARNESS_REPORT_PATH)).mtimeMs;
2171
+ }
2172
+ catch {
2173
+ rootMtimeMs = 0;
2174
+ }
2175
+ const fresher = [];
2176
+ let visited = 0;
2177
+ const walk = (dir, depth) => {
2178
+ if (depth > FRESHER_REPORT_MAX_DEPTH || visited >= FRESHER_REPORT_MAX_DIRS)
2179
+ return;
2180
+ visited += 1;
2181
+ let entries;
2182
+ try {
2183
+ entries = readdirSync(dir, { withFileTypes: true });
2184
+ }
2185
+ catch {
2186
+ return;
2187
+ }
2188
+ for (const entry of entries) {
2189
+ // Dot-dirs (including each dir's own `.moda`, checked explicitly below)
2190
+ // and scan-skipped dirs never host a user project subtree.
2191
+ if (!entry.isDirectory() || entry.name.startsWith('.') || SKIPPED_DIRS.has(entry.name))
2192
+ continue;
2193
+ const child = join(dir, entry.name);
2194
+ try {
2195
+ const candidate = lstatSync(join(child, HARNESS_REPORT_PATH));
2196
+ if (candidate.isFile() && candidate.mtimeMs > rootMtimeMs) {
2197
+ fresher.push({
2198
+ path: normalizePath(relative(rootDir, join(child, HARNESS_REPORT_PATH))),
2199
+ mtimeMs: candidate.mtimeMs,
2200
+ });
2201
+ }
2202
+ }
2203
+ catch {
2204
+ // No report in this subdir; keep walking.
2205
+ }
2206
+ walk(child, depth + 1);
2207
+ }
2208
+ };
2209
+ walk(rootDir, 1);
2210
+ return fresher.sort((a, b) => b.mtimeMs - a.mtimeMs);
2211
+ }
2212
+ /**
2213
+ * `--path` trap guard for `harness approve` / `harness sync`: when the
2214
+ * report the command is about to use is older than a fresher
2215
+ * `<subdir>/.moda/harness-report.json`, point at the fresher one. Skipped
2216
+ * when the user explicitly named a report file (they already chose).
2217
+ */
2218
+ function warnFresherSubdirHarnessReports(context, rootDir, explicitReport) {
2219
+ // Only treat an explicit report as an opt-out when it points somewhere other
2220
+ // than the default root report. `--from-report=.moda/harness-report.json`
2221
+ // still targets the default report, so the fresher-subdir warning must fire.
2222
+ const explicitReportPath = explicitReport && explicitReport !== 'true'
2223
+ ? normalizePath(relative(rootDir, resolve(rootDir, explicitReport)))
2224
+ : undefined;
2225
+ if (explicitReportPath && explicitReportPath !== normalizePath(HARNESS_REPORT_PATH))
2226
+ return;
2227
+ let fresher;
2228
+ try {
2229
+ fresher = findFresherHarnessReports(rootDir);
2230
+ }
2231
+ catch {
2232
+ return;
2233
+ }
2234
+ if (fresher.length === 0)
2235
+ return;
2236
+ const newest = fresher[0];
2237
+ const subdir = normalizePath(dirname(dirname(newest.path)));
2238
+ const rootState = existsSync(join(rootDir, HARNESS_REPORT_PATH))
2239
+ ? `older ${HARNESS_REPORT_PATH}`
2240
+ : `no ${HARNESS_REPORT_PATH}`;
2241
+ writeHarnessNotice(context, `A fresher harness report exists at ${newest.path} (this directory has ${rootState}). ` +
2242
+ `If you meant that report, re-run with --path=${subdir}.`);
2052
2243
  }
2053
2244
  function readHarnessGraph(manifestPath) {
2054
2245
  return JSON.parse(readFileSync(manifestPath, 'utf8'));
@@ -11,7 +11,7 @@ export interface InitPhaseResult {
11
11
  details?: string;
12
12
  }
13
13
  export interface InitSetupAction {
14
- category: 'sdk' | 'harness' | 'hooks' | 'prompts' | 'skills' | 'ci-cd' | 'autosync' | 'validation';
14
+ category: 'sdk' | 'harness' | 'hooks' | 'prompts' | 'skills' | 'ci-cd' | 'autosync' | 'harness-rescan' | 'validation';
15
15
  title: string;
16
16
  status: 'planned' | 'applied' | 'skipped' | 'manual';
17
17
  approved: boolean;
@@ -28,6 +28,44 @@ export interface InitRunOptions {
28
28
  onEvent?: (event: InitProgressEvent) => void;
29
29
  }
30
30
  export declare function runInit(flags: Record<string, unknown>, options?: InitRunOptions): Promise<InitResult>;
31
+ export declare function writeCiCdWorkflow(cwd?: string): string;
32
+ export declare function writeAutosyncWorkflow(cwd?: string): string;
33
+ export declare function writeHarnessRescanWorkflow(cwd?: string, paths?: string[]): string;
34
+ /**
35
+ * Signals from the discovered graph used by the init 0-agent quality gate:
36
+ * runtime agent count plus the LLM framework/provider names detected in the
37
+ * repo.
38
+ */
39
+ export interface HarnessQualitySignals {
40
+ runtimeAgents: number;
41
+ frameworks: string[];
42
+ providers: string[];
43
+ }
44
+ /**
45
+ * Read the gate signals from the SAME graph the auto-sync uploads: the cited
46
+ * report (`.moda/harness-report.json`, consumed by `harness sync --from-report`)
47
+ * is authoritative, so read its `graph` first. Only fall back to the standalone
48
+ * manifest (`.moda/harness.json`) when no report exists, otherwise a stale or
49
+ * missing manifest could let an empty report slip past the gate and overwrite
50
+ * the tenant's harness. Returns undefined when neither is readable.
51
+ */
52
+ export declare function readHarnessQualitySignals(cwd?: string): HarnessQualitySignals | undefined;
53
+ /**
54
+ * True when discovery found no runtime agents even though the repo carries
55
+ * LLM framework/provider signals — the signature of a failed discovery run,
56
+ * not an agent-free repo.
57
+ */
58
+ export declare function harnessLooksIncomplete(signals: HarnessQualitySignals): boolean;
59
+ /**
60
+ * Deterministic repo-side LLM signals: well-known LLM package names found in
61
+ * dependency manifests. Independent of the discovery output, so a fully
62
+ * failed discovery (empty graph) cannot bypass the 0-agent gate.
63
+ */
64
+ export declare function detectRepoLlmSignals(cwd?: string): {
65
+ frameworks: string[];
66
+ providers: string[];
67
+ };
68
+ export declare function resumeFlagsForInit(flags: Record<string, unknown>): string[];
31
69
  /**
32
70
  * Onboarding runs harness discovery SERVER-SIDE by default so the customer
33
71
  * needs no local coding agent. Opt out with `--local`, `--no-remote`,
@@ -5,7 +5,7 @@
5
5
  // harness analysis first and overlapping it with the fast local tasks and the
6
6
  // SDK integration agent while a live progress board (progress-board.ts)
7
7
  // renders each lane.
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { checkbox, confirm, select } from '@inquirer/prompts';
11
11
  import { login, loadToken } from '../auth.js';
@@ -20,7 +20,7 @@ import { InitProgressBoard } from './progress-board.js';
20
20
  import { createCommandContext } from '../command-context.js';
21
21
  import { resolveModaBaseUrl, VERSION } from '../config.js';
22
22
  import { CliAuthError, CliInputError } from '../errors.js';
23
- import { detectAvailableCodingAgents, isDeterministicLocalAnalyst, runHarnessCommand, } from '../harness.js';
23
+ import { describeSyncIdentity, describeSyncTenantLabel, detectAvailableCodingAgents, isDeterministicLocalAnalyst, runHarnessCommand, } from '../harness.js';
24
24
  import { pollRemoteAnalyzeUntilDone, startRemoteAnalyze, } from '../harness-remote.js';
25
25
  import { buildDoctorReport } from '../doctor.js';
26
26
  import { renderInitBanner, renderInitComplete, renderInitNote, renderInitSetupPlan, renderInitStep, } from '../tui.js';
@@ -113,7 +113,7 @@ export async function runInit(flags, options = {}) {
113
113
  // selection; otherwise the exact-id match below fails with
114
114
  // "No organization with id ''" instead of auto-selecting a sole org.
115
115
  const tenantIdEnv = stringFlag(process.env.MODA_TENANT_ID);
116
- const { apiKey, tenantName } = await selectTenantAndCreateKey({
116
+ const { apiKey, tenantId: selectedTenantId, tenantName } = await selectTenantAndCreateKey({
117
117
  tenantId: tenantIdFlag ?? tenantIdEnv,
118
118
  tenantIdSource: tenantIdFlag ? 'flag' : tenantIdEnv ? 'env' : undefined,
119
119
  nonInteractive: json || agent,
@@ -134,6 +134,11 @@ export async function runInit(flags, options = {}) {
134
134
  const sdkFlag = resolveSdkIntegrateFlag(flags);
135
135
  const ciCdFlag = resolveCiCdFlag(flags);
136
136
  const autosyncFlag = resolveAutosyncFlag(flags);
137
+ const harnessRescanFlag = resolveHarnessRescanFlag(flags);
138
+ const harnessRescanPaths = parseHarnessRescanPaths(flagValue(flags, 'harness-rescan-paths', 'harness_rescan_paths'));
139
+ // Passing --harness-rescan-paths alone is an explicit opt-in: scoping the
140
+ // rescan only makes sense when the workflow is being written.
141
+ const harnessRescanChoice = harnessRescanFlag ?? (harnessRescanPaths !== undefined ? true : undefined);
137
142
  // SDK integration installs packages and edits source via a coding agent, so
138
143
  // it defaults ON only when an agent CLI is installed to do the work; it
139
144
  // never runs unattended without an explicit --sdk-integrate.
@@ -141,6 +146,7 @@ export async function runInit(flags, options = {}) {
141
146
  let integrateSdk = sdkFlag !== undefined ? sdkFlag : yes ? false : codingAgentAvailable;
142
147
  let configureCiCd = ciCdFlag ?? false;
143
148
  let configureAutosync = autosyncFlag ?? false;
149
+ let configureHarnessRescan = harnessRescanChoice ?? false;
144
150
  let setupPlan = [];
145
151
  const renderPlan = () => {
146
152
  setupPlan = buildSetupPlan({
@@ -148,6 +154,7 @@ export async function runInit(flags, options = {}) {
148
154
  integrateSdk,
149
155
  configureCiCd,
150
156
  configureAutosync,
157
+ configureHarnessRescan,
151
158
  remote,
152
159
  });
153
160
  log(renderInitSetupPlan(setupPlan, { snapshotConsent: remote }));
@@ -185,6 +192,13 @@ export async function runInit(flags, options = {}) {
185
192
  checked: configureAutosync,
186
193
  });
187
194
  }
195
+ if (harnessRescanChoice === undefined) {
196
+ choices.push({
197
+ name: 'Re-scan the harness on push to main (.github/workflows/moda-harness-rescan.yml)',
198
+ value: 'harness-rescan',
199
+ checked: configureHarnessRescan,
200
+ });
201
+ }
188
202
  if (choices.length > 0) {
189
203
  const selected = await checkbox({
190
204
  message: 'Choose what runs (space toggles, enter confirms)',
@@ -198,13 +212,16 @@ export async function runInit(flags, options = {}) {
198
212
  configureCiCd = selected.includes('ci-cd');
199
213
  if (autosyncFlag === undefined)
200
214
  configureAutosync = selected.includes('autosync');
215
+ if (harnessRescanChoice === undefined)
216
+ configureHarnessRescan = selected.includes('harness-rescan');
201
217
  renderPlan();
202
218
  }
203
219
  }
204
220
  }
205
221
  if (json || agent) {
206
222
  const skipped = setupPlan
207
- .filter((action) => (action.category === 'sdk' || action.category === 'ci-cd' || action.category === 'autosync') &&
223
+ .filter((action) => (action.category === 'sdk' || action.category === 'ci-cd' || action.category === 'autosync' ||
224
+ action.category === 'harness-rescan') &&
208
225
  action.approved === false)
209
226
  .map((action) => `${action.category}: ${action.command ?? 'explicit flag required'}`);
210
227
  if (skipped.length > 0) {
@@ -267,6 +284,61 @@ export async function runInit(flags, options = {}) {
267
284
  // unapproved report, so approve it first. Best-effort: a network blip on
268
285
  // either call must not abort onboarding.
269
286
  const approveAndSyncHarness = async () => {
287
+ // Quality gate: a result with ZERO runtime agents on a repo that clearly
288
+ // uses LLM frameworks/providers almost always means discovery failed (bad
289
+ // snapshot, analyst error), not an agent-free repo. Auto-approving and
290
+ // syncing it would overwrite the tenant's harness with an empty graph, so
291
+ // withhold the upload and hand the user the retry commands instead.
292
+ // The framework/provider evidence is the union of what the discovered
293
+ // graph self-reports AND a deterministic repo-side dependency scan — a
294
+ // TOTAL discovery failure (0 agents, 0 frameworks, 0 providers) must not
295
+ // bypass the gate just because the artifact being judged is empty.
296
+ const graphSignals = readHarnessQualitySignals(rootDir);
297
+ const repoSignals = detectRepoLlmSignals(rootDir);
298
+ const signals = graphSignals
299
+ ? {
300
+ runtimeAgents: graphSignals.runtimeAgents,
301
+ frameworks: [...new Set([...graphSignals.frameworks, ...repoSignals.frameworks])],
302
+ providers: [...new Set([...graphSignals.providers, ...repoSignals.providers])],
303
+ }
304
+ : undefined;
305
+ if (signals && harnessLooksIncomplete(signals)) {
306
+ const names = [...signals.frameworks, ...signals.providers].join(', ');
307
+ const retryCommand = remote
308
+ ? 'moda harness analyze --remote --summary'
309
+ : 'moda harness analyze --analyst=claude|cursor|codex --summary';
310
+ warn(renderInitNote(`Harness discovery found 0 runtime agents, but this repo has LLM framework signals (${names}). ` +
311
+ 'Discovery likely failed, so the result was NOT approved or synced.', 'warning'));
312
+ warn(` Retry with \`${retryCommand}\`, then \`moda harness approve --yes && moda harness sync --from-report\`.`);
313
+ emit('harness', 'Harness discovery found 0 agents despite LLM framework signals; sync withheld.', 'warning');
314
+ harnessPhase = {
315
+ id: 'harness',
316
+ title: 'Harness discovery',
317
+ status: 'warning',
318
+ details: '0 agents detected; sync withheld',
319
+ };
320
+ return;
321
+ }
322
+ // Identity banner: the credential is resolved with the same precedence
323
+ // the upload uses (MODA_API_KEY env wins) and the tenant comes from the
324
+ // WINNING KEY itself (or is marked unverified), so a shadowed/stale key
325
+ // is visible — and never mislabeled — BEFORE the graph lands in the
326
+ // wrong tenant. Best-effort: must not abort onboarding.
327
+ try {
328
+ const identity = describeSyncIdentity({ profile, env: process.env });
329
+ if (identity) {
330
+ // The friendly workspace name only decorates the id when the winning
331
+ // key's embedded tenant IS the tenant init just selected — otherwise
332
+ // the raw (possibly surprising) destination id stands alone.
333
+ const tenantLabel = identity.tenantSource === 'api-key' && identity.tenantId
334
+ ? `${identity.tenantId}${identity.tenantId === selectedTenantId ? ` (${tenantName})` : ''}`
335
+ : describeSyncTenantLabel(identity);
336
+ log(renderInitNote(`Syncing as ${identity.credential} → tenant ${tenantLabel}`, 'info'));
337
+ }
338
+ }
339
+ catch {
340
+ // Advisory banner only.
341
+ }
270
342
  try {
271
343
  await runHarnessCommand(createCommandContext({
272
344
  command: 'harness',
@@ -437,6 +509,12 @@ export async function runInit(flags, options = {}) {
437
509
  laneBoard?.complete('autosync', `wrote ${workflowPath}`);
438
510
  markApplied(setupPlan, 'autosync');
439
511
  }
512
+ if (setupPlan.find((action) => action.category === 'harness-rescan')?.approved === true) {
513
+ const workflowPath = writeHarnessRescanWorkflow(process.cwd(), harnessRescanPaths);
514
+ laneBoard?.startLane('harness-rescan', 'Harness rescan workflow');
515
+ laneBoard?.complete('harness-rescan', `wrote ${workflowPath}`);
516
+ markApplied(setupPlan, 'harness-rescan');
517
+ }
440
518
  const sdkAction = setupPlan.find((action) => action.category === 'sdk');
441
519
  if (sdkAction?.approved === true) {
442
520
  laneBoard?.startLane('sdk', 'SDK integration', {
@@ -667,6 +745,17 @@ function buildSetupPlan(options) {
667
745
  command: 'moda prompts sync && moda skills sync',
668
746
  reason: options.configureAutosync ? undefined : 'No autosync workflow is written until it is explicitly approved (--autosync).',
669
747
  },
748
+ {
749
+ category: 'harness-rescan',
750
+ title: 'Re-scan the harness on push to main so the synced graph never drifts from the code',
751
+ status: options.configureHarnessRescan ? 'planned' : 'manual',
752
+ approved: options.configureHarnessRescan,
753
+ files: ['.github/workflows/moda-harness-rescan.yml'],
754
+ command: 'moda harness analyze --remote --yes && moda harness approve --yes && moda harness sync --from-report',
755
+ reason: options.configureHarnessRescan
756
+ ? undefined
757
+ : 'No harness rescan workflow is written until it is explicitly approved (--harness-rescan).',
758
+ },
670
759
  {
671
760
  category: 'validation',
672
761
  title: 'Run Moda doctor online to confirm telemetry is reaching Moda',
@@ -701,7 +790,14 @@ function changedFiles(setupPlan) {
701
790
  // the Moda registry. The pin tracks the CLI that generated the file, so each
702
791
  // `moda init` upgrade is an explicit, reviewable bump of the workflow.
703
792
  const GENERATED_WORKFLOW_CLI = `@moda-ai/cli@${VERSION}`;
704
- function writeCiCdWorkflow(cwd = process.cwd()) {
793
+ // npx dispatch shape matters: `npx -y @moda-ai/cli@X moda harness ...` resolves
794
+ // the package's default bin and passes the literal `moda` token through as the
795
+ // CLI's first argument, so every step would exit 1 with "Unknown command
796
+ // 'moda'". `--package=<pkg> moda` pins the package AND names the `moda` bin
797
+ // explicitly, so the CLI receives the real subcommand first.
798
+ const GENERATED_WORKFLOW_NPX = `npx -y --package=${GENERATED_WORKFLOW_CLI} moda`;
799
+ // Exported for tests (generated-workflows.test.ts validates npx dispatch shape).
800
+ export function writeCiCdWorkflow(cwd = process.cwd()) {
705
801
  const relativePath = '.github/workflows/moda-validation.yml';
706
802
  const path = join(cwd, relativePath);
707
803
  mkdirSync(dirname(path), { recursive: true });
@@ -730,7 +826,7 @@ function writeCiCdWorkflow(cwd = process.cwd()) {
730
826
  ' with:',
731
827
  ' node-version: 22',
732
828
  ' - name: Validate Moda setup',
733
- ` run: npx -y ${GENERATED_WORKFLOW_CLI} moda doctor --online --json`,
829
+ ` run: ${GENERATED_WORKFLOW_NPX} doctor --online --json`,
734
830
  '',
735
831
  ].join('\n')}`);
736
832
  return relativePath;
@@ -739,7 +835,7 @@ function writeCiCdWorkflow(cwd = process.cwd()) {
739
835
  // sync with the repo: it pushes prompt and skill changes after they merge. It
740
836
  // only runs on `main` (never on pull_request) so unmerged versions are never
741
837
  // published. Keep its env in step with writeCiCdWorkflow().
742
- function writeAutosyncWorkflow(cwd = process.cwd()) {
838
+ export function writeAutosyncWorkflow(cwd = process.cwd()) {
743
839
  const relativePath = '.github/workflows/moda-autosync.yml';
744
840
  const path = join(cwd, relativePath);
745
841
  mkdirSync(dirname(path), { recursive: true });
@@ -772,9 +868,72 @@ function writeAutosyncWorkflow(cwd = process.cwd()) {
772
868
  ' with:',
773
869
  ' node-version: 22',
774
870
  ` - name: Sync prompts to Moda`,
775
- ` run: npx -y ${GENERATED_WORKFLOW_CLI} moda prompts sync`,
871
+ ` run: ${GENERATED_WORKFLOW_NPX} prompts sync`,
776
872
  ` - name: Sync skills to Moda`,
777
- ` run: npx -y ${GENERATED_WORKFLOW_CLI} moda skills sync`,
873
+ ` run: ${GENERATED_WORKFLOW_NPX} skills sync`,
874
+ '',
875
+ ].join('\n')}`);
876
+ return relativePath;
877
+ }
878
+ // Generates the "living harness" rescan workflow: after every merge to main
879
+ // the hosted analyst re-analyzes the repo, the refreshed report is approved
880
+ // non-interactively, and the cited graph is synced to Moda — so the harness
881
+ // Moda serves never drifts from the code. It runs only on push to `main`
882
+ // (plus manual workflow_dispatch), never on pull_request, so unmerged agent
883
+ // changes are never synced. An optional path scope limits pushes that trigger
884
+ // a rescan. Keep its env in step with writeCiCdWorkflow().
885
+ export function writeHarnessRescanWorkflow(cwd = process.cwd(), paths) {
886
+ const relativePath = '.github/workflows/moda-harness-rescan.yml';
887
+ const path = join(cwd, relativePath);
888
+ mkdirSync(dirname(path), { recursive: true });
889
+ const pathScope = paths !== undefined && paths.length > 0
890
+ ? [
891
+ ' # Only pushes touching these paths trigger a rescan.',
892
+ ' paths:',
893
+ ...paths.map((glob) => ` - '${glob.replace(/'/g, "''")}'`),
894
+ ]
895
+ : [
896
+ ' # Optional: scope rescans to agent-relevant paths, e.g.',
897
+ ' # paths:',
898
+ " # - 'src/agents/**'",
899
+ ];
900
+ writeFileSync(path, `${[
901
+ 'name: Moda Harness Rescan',
902
+ '',
903
+ 'on:',
904
+ ' push:',
905
+ ' branches: [main]',
906
+ ...pathScope,
907
+ ' workflow_dispatch:',
908
+ '',
909
+ '# Least privilege: this job reads the checked-out repo, uploads a safe',
910
+ '# source snapshot to Moda over the API key, and syncs the refreshed harness',
911
+ '# graph; it never writes back to the repository.',
912
+ 'permissions:',
913
+ ' contents: read',
914
+ '',
915
+ '# One in-flight rescan at a time; newer pushes supersede older runs.',
916
+ 'concurrency:',
917
+ ' group: moda-harness-rescan',
918
+ ' cancel-in-progress: true',
919
+ '',
920
+ 'jobs:',
921
+ ' moda-harness-rescan:',
922
+ ' runs-on: ubuntu-latest',
923
+ ' env:',
924
+ ' MODA_API_KEY: ${{ secrets.MODA_API_KEY }}',
925
+ ' MODA_TENANT_ID: ${{ secrets.MODA_TENANT_ID }}',
926
+ ' steps:',
927
+ ' - uses: actions/checkout@v4',
928
+ ' - uses: actions/setup-node@v4',
929
+ ' with:',
930
+ ' node-version: 22',
931
+ ' - name: Re-analyze the harness on Moda',
932
+ ` run: ${GENERATED_WORKFLOW_NPX} harness analyze --remote --yes`,
933
+ ' - name: Approve the refreshed harness report',
934
+ ` run: ${GENERATED_WORKFLOW_NPX} harness approve --yes`,
935
+ ' - name: Sync the harness graph to Moda',
936
+ ` run: ${GENERATED_WORKFLOW_NPX} harness sync --from-report`,
778
937
  '',
779
938
  ].join('\n')}`);
780
939
  return relativePath;
@@ -802,6 +961,225 @@ function printHarnessDetectionSummary(cwd = process.cwd()) {
802
961
  console.log(' Detected harness summary is available in .moda/HARNESS.md');
803
962
  }
804
963
  }
964
+ function signalsFromGraph(graph) {
965
+ const names = (items) => (items ?? [])
966
+ .map((item) => (typeof item.name === 'string' ? item.name : undefined))
967
+ .filter((name) => Boolean(name));
968
+ return {
969
+ runtimeAgents: Array.isArray(graph.runtimeAgents) ? graph.runtimeAgents.length : 0,
970
+ frameworks: names(graph.agentFrameworks),
971
+ providers: names(graph.modelProviders),
972
+ };
973
+ }
974
+ /**
975
+ * Read the gate signals from the SAME graph the auto-sync uploads: the cited
976
+ * report (`.moda/harness-report.json`, consumed by `harness sync --from-report`)
977
+ * is authoritative, so read its `graph` first. Only fall back to the standalone
978
+ * manifest (`.moda/harness.json`) when no report exists, otherwise a stale or
979
+ * missing manifest could let an empty report slip past the gate and overwrite
980
+ * the tenant's harness. Returns undefined when neither is readable.
981
+ */
982
+ export function readHarnessQualitySignals(cwd = process.cwd()) {
983
+ const reportPath = join(cwd, '.moda', 'harness-report.json');
984
+ if (existsSync(reportPath)) {
985
+ try {
986
+ const report = JSON.parse(readFileSync(reportPath, 'utf8'));
987
+ if (report.graph && typeof report.graph === 'object') {
988
+ return signalsFromGraph(report.graph);
989
+ }
990
+ }
991
+ catch {
992
+ // Fall through to the manifest below.
993
+ }
994
+ }
995
+ const manifestPath = join(cwd, '.moda', 'harness.json');
996
+ if (!existsSync(manifestPath))
997
+ return undefined;
998
+ try {
999
+ return signalsFromGraph(JSON.parse(readFileSync(manifestPath, 'utf8')));
1000
+ }
1001
+ catch {
1002
+ return undefined;
1003
+ }
1004
+ }
1005
+ /**
1006
+ * True when discovery found no runtime agents even though the repo carries
1007
+ * LLM framework/provider signals — the signature of a failed discovery run,
1008
+ * not an agent-free repo.
1009
+ */
1010
+ export function harnessLooksIncomplete(signals) {
1011
+ return signals.runtimeAgents === 0 &&
1012
+ (signals.frameworks.length > 0 || signals.providers.length > 0);
1013
+ }
1014
+ // --- Repo-side LLM dependency scan (0-agent gate evidence) -----------------
1015
+ //
1016
+ // The gate must not read its "repo has LLM signals" evidence solely from the
1017
+ // artifact it is gating: a TOTAL discovery failure emits a graph with 0
1018
+ // agents AND 0 frameworks/providers, which would sail past a graph-only
1019
+ // check and overwrite the tenant's harness with an empty graph. This scan is
1020
+ // deterministic and independent of the analyst: it looks for well-known LLM
1021
+ // packages in dependency manifests (package.json, requirements.txt,
1022
+ // pyproject.toml) via a bounded walk.
1023
+ const REPO_SIGNAL_MAX_DEPTH = 3;
1024
+ const REPO_SIGNAL_MAX_DIRS = 500;
1025
+ const REPO_SIGNAL_SKIPPED_DIRS = new Set([
1026
+ 'node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', 'target',
1027
+ 'venv', 'env', '__pycache__', 'site-packages',
1028
+ ]);
1029
+ /** Exact npm package name → signal. */
1030
+ const JS_LLM_DEPENDENCIES = {
1031
+ openai: { name: 'OpenAI', kind: 'provider' },
1032
+ '@anthropic-ai/sdk': { name: 'Anthropic', kind: 'provider' },
1033
+ '@google/generative-ai': { name: 'Google Gemini', kind: 'provider' },
1034
+ '@google/genai': { name: 'Google Gemini', kind: 'provider' },
1035
+ 'cohere-ai': { name: 'Cohere', kind: 'provider' },
1036
+ 'groq-sdk': { name: 'Groq', kind: 'provider' },
1037
+ '@mistralai/mistralai': { name: 'Mistral', kind: 'provider' },
1038
+ ollama: { name: 'Ollama', kind: 'provider' },
1039
+ 'together-ai': { name: 'Together', kind: 'provider' },
1040
+ '@aws-sdk/client-bedrock-runtime': { name: 'AWS Bedrock', kind: 'provider' },
1041
+ ai: { name: 'Vercel AI SDK', kind: 'framework' },
1042
+ langchain: { name: 'LangChain', kind: 'framework' },
1043
+ llamaindex: { name: 'LlamaIndex', kind: 'framework' },
1044
+ '@anthropic-ai/claude-agent-sdk': { name: 'Claude Agent SDK', kind: 'framework' },
1045
+ '@openai/agents': { name: 'OpenAI Agents', kind: 'framework' },
1046
+ '@mastra/core': { name: 'Mastra', kind: 'framework' },
1047
+ };
1048
+ /** npm scope/prefix → signal (monorepo-style split packages). */
1049
+ const JS_LLM_DEPENDENCY_PREFIXES = [
1050
+ ['@langchain/', { name: 'LangChain', kind: 'framework' }],
1051
+ ['@ai-sdk/', { name: 'Vercel AI SDK', kind: 'framework' }],
1052
+ ['@llamaindex/', { name: 'LlamaIndex', kind: 'framework' }],
1053
+ ];
1054
+ /** Normalized (lowercase, `_`→`-`) Python distribution name → signal. */
1055
+ const PY_LLM_DEPENDENCIES = {
1056
+ openai: { name: 'OpenAI', kind: 'provider' },
1057
+ anthropic: { name: 'Anthropic', kind: 'provider' },
1058
+ 'google-generativeai': { name: 'Google Gemini', kind: 'provider' },
1059
+ 'google-genai': { name: 'Google Gemini', kind: 'provider' },
1060
+ cohere: { name: 'Cohere', kind: 'provider' },
1061
+ groq: { name: 'Groq', kind: 'provider' },
1062
+ mistralai: { name: 'Mistral', kind: 'provider' },
1063
+ ollama: { name: 'Ollama', kind: 'provider' },
1064
+ langgraph: { name: 'LangGraph', kind: 'framework' },
1065
+ litellm: { name: 'LiteLLM', kind: 'framework' },
1066
+ crewai: { name: 'CrewAI', kind: 'framework' },
1067
+ autogen: { name: 'AutoGen', kind: 'framework' },
1068
+ pyautogen: { name: 'AutoGen', kind: 'framework' },
1069
+ ag2: { name: 'AutoGen', kind: 'framework' },
1070
+ 'pydantic-ai': { name: 'Pydantic AI', kind: 'framework' },
1071
+ 'openai-agents': { name: 'OpenAI Agents', kind: 'framework' },
1072
+ 'semantic-kernel': { name: 'Semantic Kernel', kind: 'framework' },
1073
+ dspy: { name: 'DSPy', kind: 'framework' },
1074
+ 'haystack-ai': { name: 'Haystack', kind: 'framework' },
1075
+ };
1076
+ /** Python prefix matches (langchain, langchain-openai, llama-index-core, ...). */
1077
+ const PY_LLM_DEPENDENCY_PREFIXES = [
1078
+ ['langchain', { name: 'LangChain', kind: 'framework' }],
1079
+ ['llama-index', { name: 'LlamaIndex', kind: 'framework' }],
1080
+ ];
1081
+ function jsDependencySignal(dep) {
1082
+ const exact = JS_LLM_DEPENDENCIES[dep];
1083
+ if (exact)
1084
+ return exact;
1085
+ for (const [prefix, signal] of JS_LLM_DEPENDENCY_PREFIXES) {
1086
+ if (dep.startsWith(prefix))
1087
+ return signal;
1088
+ }
1089
+ return undefined;
1090
+ }
1091
+ function pyDependencySignal(dep) {
1092
+ const normalized = dep.toLowerCase().replace(/_/g, '-');
1093
+ const exact = PY_LLM_DEPENDENCIES[normalized];
1094
+ if (exact)
1095
+ return exact;
1096
+ for (const [prefix, signal] of PY_LLM_DEPENDENCY_PREFIXES) {
1097
+ if (normalized === prefix || normalized.startsWith(`${prefix}-`))
1098
+ return signal;
1099
+ }
1100
+ return undefined;
1101
+ }
1102
+ /** Requirement names from requirements.txt lines / pyproject.toml specifiers. */
1103
+ function pythonRequirementNames(text, file) {
1104
+ const names = [];
1105
+ if (file === 'requirements.txt') {
1106
+ for (const line of text.split('\n')) {
1107
+ const match = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(line);
1108
+ if (match)
1109
+ names.push(match[1]);
1110
+ }
1111
+ return names;
1112
+ }
1113
+ // pyproject.toml: dependency specifiers are quoted strings whose leading
1114
+ // token is the distribution name ("openai>=1.0", "langchain-openai", ...).
1115
+ for (const match of text.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)[^"']*["']/g)) {
1116
+ names.push(match[1]);
1117
+ }
1118
+ return names;
1119
+ }
1120
+ /**
1121
+ * Deterministic repo-side LLM signals: well-known LLM package names found in
1122
+ * dependency manifests. Independent of the discovery output, so a fully
1123
+ * failed discovery (empty graph) cannot bypass the 0-agent gate.
1124
+ */
1125
+ export function detectRepoLlmSignals(cwd = process.cwd()) {
1126
+ const frameworks = new Set();
1127
+ const providers = new Set();
1128
+ const record = (signal) => {
1129
+ if (!signal)
1130
+ return;
1131
+ (signal.kind === 'framework' ? frameworks : providers).add(signal.name);
1132
+ };
1133
+ const scanDir = (dir) => {
1134
+ try {
1135
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
1136
+ for (const dep of [
1137
+ ...Object.keys(pkg.dependencies ?? {}),
1138
+ ...Object.keys(pkg.devDependencies ?? {}),
1139
+ ...Object.keys(pkg.peerDependencies ?? {}),
1140
+ ]) {
1141
+ record(jsDependencySignal(dep));
1142
+ }
1143
+ }
1144
+ catch {
1145
+ // No/invalid package.json here.
1146
+ }
1147
+ for (const file of ['requirements.txt', 'pyproject.toml']) {
1148
+ try {
1149
+ const text = readFileSync(join(dir, file), 'utf8');
1150
+ for (const name of pythonRequirementNames(text, file)) {
1151
+ record(pyDependencySignal(name));
1152
+ }
1153
+ }
1154
+ catch {
1155
+ // No such manifest here.
1156
+ }
1157
+ }
1158
+ };
1159
+ let visited = 0;
1160
+ const walk = (dir, depth) => {
1161
+ if (depth > REPO_SIGNAL_MAX_DEPTH || visited >= REPO_SIGNAL_MAX_DIRS)
1162
+ return;
1163
+ visited += 1;
1164
+ scanDir(dir);
1165
+ let entries;
1166
+ try {
1167
+ entries = readdirSync(dir, { withFileTypes: true });
1168
+ }
1169
+ catch {
1170
+ return;
1171
+ }
1172
+ for (const entry of entries) {
1173
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
1174
+ continue;
1175
+ if (REPO_SIGNAL_SKIPPED_DIRS.has(entry.name))
1176
+ continue;
1177
+ walk(join(dir, entry.name), depth + 1);
1178
+ }
1179
+ };
1180
+ walk(cwd, 0);
1181
+ return { frameworks: [...frameworks].sort(), providers: [...providers].sort() };
1182
+ }
805
1183
  /** Agent/artifact counts for the completion screen's harness payoff line. */
806
1184
  function readHarnessGraphCounts(cwd = process.cwd()) {
807
1185
  const manifestPath = join(cwd, '.moda', 'harness.json');
@@ -826,7 +1204,7 @@ function commandFlags(flags) {
826
1204
  function stringFlag(value) {
827
1205
  return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
828
1206
  }
829
- function resumeFlagsForInit(flags) {
1207
+ export function resumeFlagsForInit(flags) {
830
1208
  const passthrough = [
831
1209
  'profile',
832
1210
  'tenant-id',
@@ -835,9 +1213,12 @@ function resumeFlagsForInit(flags) {
835
1213
  'sdk-integrate',
836
1214
  'ci-cd',
837
1215
  'autosync',
1216
+ 'harness-rescan',
1217
+ 'harness-rescan-paths',
838
1218
  'no-sdk-integrate',
839
1219
  'no-ci-cd',
840
1220
  'no-autosync',
1221
+ 'no-harness-rescan',
841
1222
  'yes',
842
1223
  'agent',
843
1224
  'json',
@@ -854,6 +1235,14 @@ function resumeFlagsForInit(flags) {
854
1235
  else
855
1236
  out.push(flag, String(value));
856
1237
  }
1238
+ // The rescan choice is derived as `flag ?? (paths ? true : ...)`, so a bare
1239
+ // `--harness-rescan-paths` re-implies opt-in. If the original run explicitly
1240
+ // disabled rescan (via `--harness-rescan=false`, whose false value the loop
1241
+ // above drops), carry the negative through so a resume can't silently write
1242
+ // the workflow the user declined.
1243
+ if (resolveHarnessRescanFlag(flags) === false && !out.includes('--no-harness-rescan')) {
1244
+ out.push('--no-harness-rescan');
1245
+ }
857
1246
  return out;
858
1247
  }
859
1248
  /**
@@ -877,6 +1266,29 @@ function resolveAutosyncFlag(flags) {
877
1266
  return false;
878
1267
  return undefined;
879
1268
  }
1269
+ /** Explicit harness-rescan decision from flags, or undefined when the Plan beat decides. */
1270
+ function resolveHarnessRescanFlag(flags) {
1271
+ const explicit = parseOptionalBoolean(flagValue(flags, 'harness-rescan', 'harness_rescan'));
1272
+ if (explicit !== undefined)
1273
+ return explicit;
1274
+ if (isTruthyFlag(flagValue(flags, 'no-harness-rescan', 'no_harness_rescan')))
1275
+ return false;
1276
+ return undefined;
1277
+ }
1278
+ /**
1279
+ * Comma-separated path globs for the rescan workflow's `on.push.paths` scope.
1280
+ * `undefined` when the flag is absent (or carries no usable globs), so a bare
1281
+ * `--harness-rescan` still writes an unscoped rescan-on-every-push workflow.
1282
+ */
1283
+ function parseHarnessRescanPaths(value) {
1284
+ if (typeof value !== 'string')
1285
+ return undefined;
1286
+ const globs = value
1287
+ .split(',')
1288
+ .map((glob) => glob.trim())
1289
+ .filter((glob) => glob.length > 0 && glob !== 'true');
1290
+ return globs.length > 0 ? globs : undefined;
1291
+ }
880
1292
  /**
881
1293
  * Explicit SDK-integration decision from flags, or undefined when the Plan
882
1294
  * beat decides (interactive default: on when a coding-agent CLI is installed;
@@ -5,7 +5,7 @@ import { hostname } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { select } from '@inquirer/prompts';
7
7
  import { authFetch, getCachedLoginContext } from '../auth.js';
8
- import { loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
8
+ import { embeddedTenantIdFromApiKey, loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
9
9
  import { getModaDir, resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, writeSecretJsonFile, } from '../profiles.js';
10
10
  import { CliAccessError, CliInputError, CliInputRequiredError } from '../errors.js';
11
11
  /**
@@ -42,8 +42,6 @@ export function formatControlPlaneFetchError(action, status, body) {
42
42
  // Mirrors the backend's checkAdminAccess: only the viewer role is denied.
43
43
  const ADMIN_ACCESS_MESSAGE = 'You need admin or owner access to create API keys.';
44
44
  const ADMIN_ACCESS_GUIDANCE = 'Ask your organization owner to upgrade your role.';
45
- const API_KEY_PREFIX = 'moda_sk_';
46
- const SIGNED_KEY_VERSION = 'v1';
47
45
  // Bound the pre-bootstrap liveness probe tightly: a slow or unreachable
48
46
  // endpoint must never block init — we just mint a fresh key instead.
49
47
  const VALIDATE_TIMEOUT_MS = 3_000;
@@ -160,29 +158,13 @@ function bootstrapLabel(custom) {
160
158
  }
161
159
  }
162
160
  /**
163
- * Signed CLI keys embed their tenant: the format is
164
- * moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
165
- * createApiKey/parseAndVerifySignedKey in
166
- * apps/moda-backend/src/api-keys/api-keys.service.ts. Decoding locally lets
167
- * dedup reject a cross-tenant or malformed key structurally, with no network
168
- * call. The HMAC signature can only be verified server-side; liveness (not
169
- * revoked) is probed separately by isStoredKeyLive().
161
+ * Signed CLI keys embed their tenant (moda_sk_<base64url("v1:keyId:tenantId:
162
+ * timestamp:signature")>). Decoding locally lets dedup reject a cross-tenant
163
+ * or malformed key structurally, with no network call. Shared with the
164
+ * pre-upload identity banner via `embeddedTenantIdFromApiKey` in config.ts;
165
+ * liveness (not revoked) is probed separately by isStoredKeyLive().
170
166
  */
171
- function embeddedTenantId(key) {
172
- if (!key.startsWith(API_KEY_PREFIX))
173
- return undefined;
174
- try {
175
- const decoded = Buffer.from(key.slice(API_KEY_PREFIX.length), 'base64url').toString('utf8');
176
- // Expected format: version:keyId:tenantId:timestamp:signature
177
- const parts = decoded.split(':');
178
- if (parts.length !== 5 || parts[0] !== SIGNED_KEY_VERSION)
179
- return undefined;
180
- return parts[2] || undefined;
181
- }
182
- catch {
183
- return undefined;
184
- }
185
- }
167
+ const embeddedTenantId = embeddedTenantIdFromApiKey;
186
168
  /**
187
169
  * Liveness probe for a stored key. Deliberately NOT the backend's public
188
170
  * POST /api/api-keys/validate: the CLI's resolved base URL is the FRONTEND
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.18.1",
3
+ "version": "1.19.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-10T23:46:28.191Z",
4
- "cli_version": "1.18.1",
3
+ "bundled_at": "2026-07-11T00:30:28.897Z",
4
+ "cli_version": "1.19.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",