@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/cli.js +8 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.js +28 -0
- package/dist/harness-candidates.d.ts +77 -0
- package/dist/harness-candidates.js +482 -0
- package/dist/harness.d.ts +63 -0
- package/dist/harness.js +245 -16
- package/dist/init/index.d.ts +39 -1
- package/dist/init/index.js +422 -10
- package/dist/init/tenant.js +7 -25
- package/dist/redact.js +9 -0
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
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
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/init/index.d.ts
CHANGED
|
@@ -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`,
|