@moda-ai/cli 1.18.0 → 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.
@@ -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