@moda-ai/cli 1.18.1 → 1.19.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/dist/harness.d.ts CHANGED
@@ -173,6 +173,12 @@ export interface HarnessGraph {
173
173
  relationships: HarnessRelationship[];
174
174
  unknowns: HarnessUnknown[];
175
175
  }
176
+ export interface FileInventoryItem {
177
+ path: string;
178
+ absPath: string;
179
+ rootDir: string;
180
+ text?: string;
181
+ }
176
182
  interface ScanOptions {
177
183
  rootDir?: string;
178
184
  dryRun?: boolean;
@@ -390,10 +396,67 @@ declare function buildCommandScanPlan(context: CommandContext, rootDir: string):
390
396
  };
391
397
  export declare function writeHumanProgress(context: CommandContext, message: string): void;
392
398
  export declare function readHarnessSyncState(rootDir: string): HarnessSyncState | undefined;
399
+ /**
400
+ * The identity a sync-style upload will actually authenticate as, resolved
401
+ * with the same precedence as the API call itself (MODA_API_KEY env wins over
402
+ * the active profile / legacy config). Used for the pre-upload
403
+ * "Syncing as <credential> → tenant <id>" banner so a shadowed or stale key
404
+ * is visible BEFORE data lands in the wrong tenant.
405
+ *
406
+ * The destination tenant is derived from the WINNING KEY itself (signed CLI
407
+ * keys embed their tenant, and the server resolves the destination from the
408
+ * key alone) — never from flag/env/profile config, which is exactly what a
409
+ * shadowing stale key disagrees with. When the key does not embed a tenant,
410
+ * the config-resolved id is returned with `tenantSource: 'config'` so
411
+ * callers must label it unverified. Best-effort: never throws (a broken
412
+ * MODA_PROFILE must not abort onboarding).
413
+ */
414
+ export declare function describeSyncIdentity(options?: {
415
+ profile?: string;
416
+ env?: NodeJS.ProcessEnv;
417
+ }): {
418
+ credential: string;
419
+ tenantId?: string;
420
+ /** 'api-key' = decoded from the winning key (authoritative); 'config' = flag/env/profile fallback (unverified). */
421
+ tenantSource?: 'api-key' | 'config';
422
+ } | undefined;
423
+ /**
424
+ * Human tenant label for the identity banner: key-derived ids are printed
425
+ * bare; config-derived ids are explicitly marked unverified so the banner
426
+ * never asserts a destination the server might not use.
427
+ */
428
+ export declare function describeSyncTenantLabel(identity: {
429
+ tenantId?: string;
430
+ tenantSource?: 'api-key' | 'config';
431
+ }): string;
432
+ /**
433
+ * Reports in subdirectories that are FRESHER than the root's
434
+ * `.moda/harness-report.json` (a missing root report counts as infinitely
435
+ * old). Catches the `--path` trap: `harness analyze --path=<subdir>` writes
436
+ * `<subdir>/.moda/harness-report.json`, and a later root-level
437
+ * `harness approve`/`sync` silently targets the stale (or absent) root report.
438
+ * Newest first.
439
+ */
440
+ export declare function findFresherHarnessReports(rootDir: string): Array<{
441
+ path: string;
442
+ mtimeMs: number;
443
+ }>;
393
444
  export declare function writeHarnessArtifacts(graph: HarnessGraph, rootDir: string, options?: {
394
445
  summary?: boolean;
395
446
  }): string[];
396
447
  export declare function graphForSync(graph: HarnessGraph): HarnessGraph;
448
+ /**
449
+ * Bounded repo walk shared by `harness scan`, snapshot upload, and the static
450
+ * candidates pre-pass: skips ignored/secret-bearing paths and symlinks,
451
+ * enforces the file-count and text-byte budgets, and reads text for known
452
+ * text extensions plus any file admitted by `options.includeFile`.
453
+ * `options.includeFile` widens (ORs with) the default inventory predicate and
454
+ * also opts its files into text reading — it can never re-admit secret-bearing
455
+ * or oversized files, which are rejected before the predicate runs.
456
+ */
457
+ export declare function inventoryFiles(rootDir: string, skipped: HarnessScanMetadata['skipped'], options?: {
458
+ includeFile?: (relPath: string) => boolean;
459
+ }): FileInventoryItem[];
397
460
  export declare function numericEnv(name: string, fallback: number): number;
398
461
  export declare function numericEnvAllowZero(name: string, fallback: number): number;
399
462
  export {};
package/dist/harness.js CHANGED
@@ -6,12 +6,13 @@ 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';
13
13
  import { enrichScanCaptureMetadata, annotatedSourceCandidates, extractToolBody, hydratePromptAndToolBodies, readPromptOrToolBody, resolveAnnotatedSourcePath, } from './harness-artifact-bodies.js';
14
14
  import { approveHarnessReport, createHarnessReport, graphForReport, hashHarnessReport, hashStableJson as hashHarnessReportStableJson, HARNESS_REPORT_PATH, loadHarnessReportForSync, normalizeHarnessReport, readHarnessReport, resolveHarnessReportPath, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
15
+ import { detectHarnessCandidates, writeHarnessCandidatesReport } from './harness-candidates.js';
15
16
  import { assertHarnessGraphSyncSize } from './harness-sync-limits.js';
16
17
  import { renderHarnessAnalystChoice, renderHarnessAnalystLaunch, renderHarnessAnalystPreflight, renderHarnessAnalyzeHumanResult, renderHarnessProgress, } from './tui.js';
17
18
  const MODA_DIR = '.moda';
@@ -101,6 +102,24 @@ export async function runHarnessCommand(context) {
101
102
  if (!resolveApiKey({ profile: context.profile, env: context.env }) && !context.dryRun) {
102
103
  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
104
  }
105
+ warnFresherSubdirHarnessReports(context, rootDir, context.flags['from-report'] ?? context.flags.report);
106
+ if (!context.dryRun) {
107
+ const identity = describeSyncIdentity({ profile: context.profile, env: context.env });
108
+ if (identity) {
109
+ // The banner's tenant comes from the winning key when it embeds
110
+ // one. Otherwise fall back to the flag/env/profile-resolved tenant
111
+ // but label it unverified — that source is precisely what a
112
+ // shadowing stale key disagrees with, so it must never be asserted
113
+ // as the destination.
114
+ const label = identity.tenantSource === 'api-key'
115
+ ? describeSyncTenantLabel(identity)
116
+ : describeSyncTenantLabel({
117
+ tenantId: tenant?.tenantId ?? identity.tenantId,
118
+ tenantSource: 'config',
119
+ });
120
+ writeHumanProgress(context, `Syncing as ${identity.credential} → tenant ${label}`);
121
+ }
122
+ }
104
123
  if (context.flags['from-report'] || context.flags.report) {
105
124
  const reportPath = resolveHarnessReportPath(rootDir, context.flags['from-report'] ?? context.flags.report);
106
125
  const { report, validation, approval, approved } = loadHarnessReportForSync(rootDir, reportPath);
@@ -344,6 +363,7 @@ export async function runHarnessCommand(context) {
344
363
  return { exitCode: validation.status === 'pass' ? 0 : 1 };
345
364
  }
346
365
  case 'approve': {
366
+ warnFresherSubdirHarnessReports(context, rootDir, context.positionals[1] ?? context.flags.report);
347
367
  const reportPath = resolveHarnessReportPath(rootDir, context.positionals[1] ?? context.flags.report);
348
368
  const approval = await approveHarnessReport({
349
369
  rootDir,
@@ -362,8 +382,31 @@ export async function runHarnessCommand(context) {
362
382
  });
363
383
  return { exitCode: 0 };
364
384
  }
385
+ case 'candidates': {
386
+ await approveScanIfNeeded(scanPlan, {
387
+ context,
388
+ message: 'Scan this project for static harness candidates?',
389
+ });
390
+ writeHumanProgress(context, 'Harness candidates: static pre-pass (no LLM, no network)...');
391
+ const report = detectHarnessCandidates(rootDir);
392
+ const written = [];
393
+ const outFlag = context.flags.out;
394
+ // A value-less `--out` is parsed as the sentinel string 'true'; reject it
395
+ // rather than silently writing the report to `<cwd>/true`.
396
+ if (outFlag === 'true') {
397
+ throw new CliInputError('Missing output path for --out.', 'Usage: moda harness candidates --out=<file>');
398
+ }
399
+ if (outFlag && !context.dryRun) {
400
+ written.push(writeHarnessCandidatesReport(report, resolve(context.cwd, outFlag)));
401
+ }
402
+ writeHumanProgress(context, `Harness candidates: ${report.stats.candidateCount} candidates across ` +
403
+ `${report.stats.filesWithCandidates} files (${report.stats.filesScanned} scanned, ` +
404
+ `${report.stats.filesPerSecond.toLocaleString()} files/sec).`);
405
+ context.output.writeData({ scanPlan, ...report, written, dryRun: context.dryRun });
406
+ return { exitCode: 0 };
407
+ }
365
408
  case 'agents': {
366
- const graph = loadOrScanHarness(rootDir, tenant);
409
+ const graph = loadOrScanHarness(rootDir, tenant, context);
367
410
  context.output.writeData({
368
411
  summary: summarizeHarness(graph),
369
412
  agentFamilies: graph.agentFamilies,
@@ -373,12 +416,12 @@ export async function runHarnessCommand(context) {
373
416
  return { exitCode: 0 };
374
417
  }
375
418
  case 'graph': {
376
- const graph = loadOrScanHarness(rootDir, tenant);
419
+ const graph = loadOrScanHarness(rootDir, tenant, context);
377
420
  context.output.writeData(graph);
378
421
  return { exitCode: 0 };
379
422
  }
380
423
  case 'explain': {
381
- const graph = loadOrScanHarness(rootDir, tenant);
424
+ const graph = loadOrScanHarness(rootDir, tenant, context);
382
425
  if (selectedAgentId) {
383
426
  const agent = graph.runtimeAgents.find((candidate) => candidate.id === selectedAgentId);
384
427
  if (!agent) {
@@ -451,7 +494,7 @@ export async function runHarnessCommand(context) {
451
494
  return { exitCode: 0 };
452
495
  }
453
496
  default:
454
- throw new CliInputError(`Unknown harness command '${subcommand}'.`, 'Usage: moda harness analyze|pull|validate-report|approve|scan|sync|agents|graph|explain|write-summary|status');
497
+ throw new CliInputError(`Unknown harness command '${subcommand}'.`, 'Usage: moda harness analyze|pull|validate-report|approve|scan|candidates|sync|agents|graph|explain|write-summary|status');
455
498
  }
456
499
  }
457
500
  export function writeHarnessAnalyzeResult(context, payload) {
@@ -2040,15 +2083,187 @@ function selectedRuntimeAgentId(flags) {
2040
2083
  return flags.agent;
2041
2084
  return undefined;
2042
2085
  }
2043
- function loadOrScanHarness(rootDir, tenant) {
2086
+ function loadOrScanHarness(rootDir, tenant, context) {
2044
2087
  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
- });
2088
+ if (existsSync(manifestPath))
2089
+ return readHarnessGraph(manifestPath);
2090
+ // Provenance banner: without a persisted manifest this command output comes
2091
+ // from a live scan of the current directory, which is easy to mistake for
2092
+ // synced/account data (especially right after switching repos or profiles).
2093
+ if (context) {
2094
+ writeHarnessNotice(context, `No ${HARNESS_PATH} found; showing a live scan of ${rootDir}. ` +
2095
+ 'This is local scan output, NOT synced or account data. ' +
2096
+ 'Run `moda harness scan` to persist it and `moda harness sync` to upload it.');
2097
+ }
2098
+ return scanHarness({
2099
+ rootDir,
2100
+ tenantId: tenant?.tenantId,
2101
+ tenantSource: tenant?.source,
2102
+ });
2103
+ }
2104
+ /**
2105
+ * Warning line that must be visible in every output mode: styled progress on
2106
+ * stderr for humans, a plain `Warning:` stderr line for machine modes (stdout
2107
+ * stays machine-readable JSON either way). Quiet suppresses it like the rest
2108
+ * of the harness progress surface.
2109
+ */
2110
+ function writeHarnessNotice(context, message) {
2111
+ if (context.quiet && context.flags.progress !== 'true')
2112
+ return;
2113
+ if (context.outputMode === 'human') {
2114
+ console.error(renderHarnessProgress(message, 'warning'));
2115
+ return;
2116
+ }
2117
+ console.error(`Warning: ${message}`);
2118
+ }
2119
+ /**
2120
+ * The identity a sync-style upload will actually authenticate as, resolved
2121
+ * with the same precedence as the API call itself (MODA_API_KEY env wins over
2122
+ * the active profile / legacy config). Used for the pre-upload
2123
+ * "Syncing as <credential> → tenant <id>" banner so a shadowed or stale key
2124
+ * is visible BEFORE data lands in the wrong tenant.
2125
+ *
2126
+ * The destination tenant is derived from the WINNING KEY itself (signed CLI
2127
+ * keys embed their tenant, and the server resolves the destination from the
2128
+ * key alone) — never from flag/env/profile config, which is exactly what a
2129
+ * shadowing stale key disagrees with. When the key does not embed a tenant,
2130
+ * the config-resolved id is returned with `tenantSource: 'config'` so
2131
+ * callers must label it unverified. Best-effort: never throws (a broken
2132
+ * MODA_PROFILE must not abort onboarding).
2133
+ */
2134
+ export function describeSyncIdentity(options = {}) {
2135
+ const apiKey = resolveApiKey(options);
2136
+ if (!apiKey)
2137
+ return undefined;
2138
+ const redacted = apiKey.length > 12
2139
+ ? `${apiKey.slice(0, 8)}…${apiKey.slice(-4)}`
2140
+ : `…${apiKey.slice(-4)}`;
2141
+ const env = options.env ?? process.env;
2142
+ // getActiveProfile throws when the named profile is missing (e.g. a stale
2143
+ // MODA_PROFILE) even though the key already resolved via legacy config —
2144
+ // the banner is advisory and must never abort the command around it.
2145
+ let profileName;
2146
+ try {
2147
+ profileName = getActiveProfile(options).name;
2148
+ }
2149
+ catch {
2150
+ profileName = undefined;
2151
+ }
2152
+ const credential = env.MODA_API_KEY === apiKey
2153
+ ? `MODA_API_KEY env key (${redacted})`
2154
+ : profileName
2155
+ ? `profile '${profileName}' key (${redacted})`
2156
+ : `stored key (${redacted})`;
2157
+ const keyTenantId = embeddedTenantIdFromApiKey(apiKey);
2158
+ if (keyTenantId) {
2159
+ return { credential, tenantId: keyTenantId, tenantSource: 'api-key' };
2160
+ }
2161
+ const configTenantId = resolveTenantId(options);
2162
+ return {
2163
+ credential,
2164
+ tenantId: configTenantId,
2165
+ tenantSource: configTenantId ? 'config' : undefined,
2166
+ };
2167
+ }
2168
+ /**
2169
+ * Human tenant label for the identity banner: key-derived ids are printed
2170
+ * bare; config-derived ids are explicitly marked unverified so the banner
2171
+ * never asserts a destination the server might not use.
2172
+ */
2173
+ export function describeSyncTenantLabel(identity) {
2174
+ if (!identity.tenantId)
2175
+ return 'unresolved';
2176
+ if (identity.tenantSource === 'api-key')
2177
+ return identity.tenantId;
2178
+ return `${identity.tenantId} (unverified — from local config, not the API key; the server derives the destination tenant from the key)`;
2179
+ }
2180
+ /** Bounded walk looking for `<subdir>/.moda/harness-report.json` files. */
2181
+ const FRESHER_REPORT_MAX_DEPTH = 4;
2182
+ const FRESHER_REPORT_MAX_DIRS = 4_000;
2183
+ /**
2184
+ * Reports in subdirectories that are FRESHER than the root's
2185
+ * `.moda/harness-report.json` (a missing root report counts as infinitely
2186
+ * old). Catches the `--path` trap: `harness analyze --path=<subdir>` writes
2187
+ * `<subdir>/.moda/harness-report.json`, and a later root-level
2188
+ * `harness approve`/`sync` silently targets the stale (or absent) root report.
2189
+ * Newest first.
2190
+ */
2191
+ export function findFresherHarnessReports(rootDir) {
2192
+ let rootMtimeMs = 0;
2193
+ try {
2194
+ rootMtimeMs = lstatSync(join(rootDir, HARNESS_REPORT_PATH)).mtimeMs;
2195
+ }
2196
+ catch {
2197
+ rootMtimeMs = 0;
2198
+ }
2199
+ const fresher = [];
2200
+ let visited = 0;
2201
+ const walk = (dir, depth) => {
2202
+ if (depth > FRESHER_REPORT_MAX_DEPTH || visited >= FRESHER_REPORT_MAX_DIRS)
2203
+ return;
2204
+ visited += 1;
2205
+ let entries;
2206
+ try {
2207
+ entries = readdirSync(dir, { withFileTypes: true });
2208
+ }
2209
+ catch {
2210
+ return;
2211
+ }
2212
+ for (const entry of entries) {
2213
+ // Dot-dirs (including each dir's own `.moda`, checked explicitly below)
2214
+ // and scan-skipped dirs never host a user project subtree.
2215
+ if (!entry.isDirectory() || entry.name.startsWith('.') || SKIPPED_DIRS.has(entry.name))
2216
+ continue;
2217
+ const child = join(dir, entry.name);
2218
+ try {
2219
+ const candidate = lstatSync(join(child, HARNESS_REPORT_PATH));
2220
+ if (candidate.isFile() && candidate.mtimeMs > rootMtimeMs) {
2221
+ fresher.push({
2222
+ path: normalizePath(relative(rootDir, join(child, HARNESS_REPORT_PATH))),
2223
+ mtimeMs: candidate.mtimeMs,
2224
+ });
2225
+ }
2226
+ }
2227
+ catch {
2228
+ // No report in this subdir; keep walking.
2229
+ }
2230
+ walk(child, depth + 1);
2231
+ }
2232
+ };
2233
+ walk(rootDir, 1);
2234
+ return fresher.sort((a, b) => b.mtimeMs - a.mtimeMs);
2235
+ }
2236
+ /**
2237
+ * `--path` trap guard for `harness approve` / `harness sync`: when the
2238
+ * report the command is about to use is older than a fresher
2239
+ * `<subdir>/.moda/harness-report.json`, point at the fresher one. Skipped
2240
+ * when the user explicitly named a report file (they already chose).
2241
+ */
2242
+ function warnFresherSubdirHarnessReports(context, rootDir, explicitReport) {
2243
+ // Only treat an explicit report as an opt-out when it points somewhere other
2244
+ // than the default root report. `--from-report=.moda/harness-report.json`
2245
+ // still targets the default report, so the fresher-subdir warning must fire.
2246
+ const explicitReportPath = explicitReport && explicitReport !== 'true'
2247
+ ? normalizePath(relative(rootDir, resolve(rootDir, explicitReport)))
2248
+ : undefined;
2249
+ if (explicitReportPath && explicitReportPath !== normalizePath(HARNESS_REPORT_PATH))
2250
+ return;
2251
+ let fresher;
2252
+ try {
2253
+ fresher = findFresherHarnessReports(rootDir);
2254
+ }
2255
+ catch {
2256
+ return;
2257
+ }
2258
+ if (fresher.length === 0)
2259
+ return;
2260
+ const newest = fresher[0];
2261
+ const subdir = normalizePath(dirname(dirname(newest.path)));
2262
+ const rootState = existsSync(join(rootDir, HARNESS_REPORT_PATH))
2263
+ ? `older ${HARNESS_REPORT_PATH}`
2264
+ : `no ${HARNESS_REPORT_PATH}`;
2265
+ writeHarnessNotice(context, `A fresher harness report exists at ${newest.path} (this directory has ${rootState}). ` +
2266
+ `If you meant that report, re-run with --path=${subdir}.`);
2052
2267
  }
2053
2268
  function readHarnessGraph(manifestPath) {
2054
2269
  return JSON.parse(readFileSync(manifestPath, 'utf8'));
@@ -2215,7 +2430,16 @@ function explainAgent(graph, agent) {
2215
2430
  relationships: graph.relationships.filter((relationship) => relationship.from === agent.id || relationship.to === agent.id),
2216
2431
  };
2217
2432
  }
2218
- function inventoryFiles(rootDir, skipped) {
2433
+ /**
2434
+ * Bounded repo walk shared by `harness scan`, snapshot upload, and the static
2435
+ * candidates pre-pass: skips ignored/secret-bearing paths and symlinks,
2436
+ * enforces the file-count and text-byte budgets, and reads text for known
2437
+ * text extensions plus any file admitted by `options.includeFile`.
2438
+ * `options.includeFile` widens (ORs with) the default inventory predicate and
2439
+ * also opts its files into text reading — it can never re-admit secret-bearing
2440
+ * or oversized files, which are rejected before the predicate runs.
2441
+ */
2442
+ export function inventoryFiles(rootDir, skipped, options = {}) {
2219
2443
  const files = [];
2220
2444
  const rootReal = resolve(rootDir);
2221
2445
  const maxFiles = numericEnv('MODA_HARNESS_MAX_FILES', DEFAULT_MAX_INVENTORY_FILES);
@@ -2255,7 +2479,7 @@ function inventoryFiles(rootDir, skipped) {
2255
2479
  recordSkipped(skipped, { path: rel, reason: 'secret-bearing file skipped' });
2256
2480
  return;
2257
2481
  }
2258
- if (!shouldInventoryFile(rel))
2482
+ if (!shouldInventoryFile(rel) && !(options.includeFile?.(rel) ?? false))
2259
2483
  return;
2260
2484
  if (files.length >= maxFiles) {
2261
2485
  throwHarnessScanTooBroad(rootReal, {
@@ -2267,7 +2491,12 @@ function inventoryFiles(rootDir, skipped) {
2267
2491
  });
2268
2492
  }
2269
2493
  const item = { path: rel, absPath: current, rootDir: rootReal };
2270
- if (isTextFile(rel)) {
2494
+ // Read text for known text extensions, but also for any file the caller
2495
+ // widened in via `includeFile` (e.g. the candidates pass admits template
2496
+ // prompt files like `.jinja`/`.hbs`/`.tpl`/`.prompt` whose extensions are
2497
+ // not in `isTextFile`) — otherwise a widened-in file is counted as scanned
2498
+ // but never gets its text read, so downstream detectors silently skip it.
2499
+ if (isTextFile(rel) || (options.includeFile?.(rel) ?? false)) {
2271
2500
  if (textBytes + stat.size > maxTextBytes) {
2272
2501
  recordSkipped(skipped, { path: rel, reason: 'scan text budget exceeded' });
2273
2502
  }
@@ -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`,