@structor-dev/cli 0.1.0 → 0.2.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +131 -21
  3. package/ROADMAP.md +38 -0
  4. package/SECURITY.md +33 -0
  5. package/bin/structor.mjs +561 -29
  6. package/contrib/self-harness/files/README.md +32 -0
  7. package/contrib/self-harness/files/ai/AGENTS.md +35 -0
  8. package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
  9. package/contrib/self-harness/files/ai/HUB.md +59 -0
  10. package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
  11. package/contrib/self-harness/files/ai/QUALITY.md +31 -0
  12. package/contrib/self-harness/files/ai/context.md +38 -0
  13. package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
  14. package/contrib/self-harness/harness.config.json +37 -0
  15. package/docs/CONTRIBUTOR-SETUP.md +45 -0
  16. package/docs/INIT.md +55 -2
  17. package/docs/public-launch.md +150 -0
  18. package/examples/anthropic-only/harness.config.json +26 -0
  19. package/examples/frontend-backend/harness.config.json +8 -8
  20. package/examples/generated-harness-tree.md +432 -0
  21. package/examples/openai-and-anthropic/harness.config.json +7 -7
  22. package/examples/single-repo/harness.config.json +7 -7
  23. package/harness.config.example.json +1 -1
  24. package/package.json +12 -4
  25. package/schemas/contract-manifest.schema.json +0 -1
  26. package/schemas/harness-config.schema.json +5 -2
  27. package/scripts/check-config.mjs +20 -31
  28. package/scripts/check-examples.mjs +146 -0
  29. package/scripts/check-placeholders.mjs +2 -0
  30. package/scripts/check-public-hygiene.mjs +249 -0
  31. package/scripts/check-schemas.mjs +42 -0
  32. package/scripts/check-template-files.mjs +15 -98
  33. package/scripts/generated-harness-contract.mjs +416 -0
  34. package/scripts/init-harness.mjs +227 -139
  35. package/scripts/lib.mjs +462 -12
  36. package/scripts/rendered-config.mjs +109 -0
  37. package/scripts/setup-contributor.mjs +125 -0
  38. package/scripts/smoke-template.mjs +260 -73
  39. package/template/AGENTS.md.tpl +4 -2
  40. package/template/README.md.tpl +5 -0
  41. package/template/ai/CODEX-HOOKS.md.tpl +1 -1
  42. package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
  43. package/template/ai/HARNESS.md.tpl +4 -1
  44. package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
  45. package/template/ai/contracts/codex-hooks.md.tpl +6 -0
  46. package/template/ai/contracts/release-flow.md.tpl +1 -1
  47. package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
  48. package/template/ai/templates/issue-template.md.tpl +3 -1
  49. package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
  50. package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
  51. package/template/consumer/AGENTS.md.tpl +4 -4
  52. package/template/consumer/CLAUDE.md.tpl +4 -4
  53. package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
  54. package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
  55. package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
  56. package/template/scripts/check-template-governance.mjs.tpl +2 -114
  57. package/template/scripts/check-workspace.mjs.tpl +27 -103
  58. package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
  59. package/template/scripts/generate-html-views.mjs.tpl +357 -56
  60. package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
  61. package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
  62. package/template/scripts/lib/path-safety.mjs.tpl +87 -0
  63. package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
  64. package/template/scripts/validate-governance.mjs.tpl +52 -36
  65. package/schemas/task-brief.schema.json +0 -37
package/bin/structor.mjs CHANGED
@@ -1,16 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "node:child_process";
4
- import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
4
+ import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
5
5
  import { constants as fsConstants } from "node:fs";
6
6
  import path from "node:path";
7
7
  import process from "node:process";
8
8
  import readline from "node:readline/promises";
9
- import { fileURLToPath } from "node:url";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import {
11
+ assertSafeConsumerPath,
12
+ hasConsumerRepositorySignal,
13
+ resolveHarnessConfig,
14
+ validateConfigShape,
15
+ } from "../scripts/lib.mjs";
16
+ import {
17
+ consumerEntrypointsForSettings,
18
+ requiredHarnessRepoFilesForWorkspaceCheck,
19
+ requiredWorkspaceFilesForWorkspaceCheck,
20
+ workspaceEntrypointsForSettings,
21
+ } from "../scripts/generated-harness-contract.mjs";
10
22
 
11
23
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
24
  const generatorPath = path.join(packageRoot, "scripts/init-harness.mjs");
13
25
  const configFileName = "harness.config.json";
26
+ const structorRepoUrlDefault = "https://github.com/nicolaycamacho/structor.git";
14
27
  const reset = "\x1b[0m";
15
28
  const styles = {
16
29
  bold: "\x1b[1m",
@@ -83,7 +96,7 @@ async function maybeReadJson(filePath) {
83
96
  }
84
97
  }
85
98
 
86
- function slugify(value) {
99
+ export function slugify(value) {
87
100
  return value
88
101
  .trim()
89
102
  .toLowerCase()
@@ -92,12 +105,12 @@ function slugify(value) {
92
105
  || "project";
93
106
  }
94
107
 
95
- function relativeFrom(basePath, targetPath) {
108
+ export function relativeFrom(basePath, targetPath) {
96
109
  const relative = path.relative(basePath, targetPath).replaceAll(path.sep, "/");
97
110
  return relative === "" ? "." : relative.startsWith(".") ? relative : `./${relative}`;
98
111
  }
99
112
 
100
- function parseArgs(argv) {
113
+ export function parseArgs(argv) {
101
114
  const [command = "help", ...rest] = argv;
102
115
  const options = { _: [] };
103
116
  for (let index = 0; index < rest.length; index += 1) {
@@ -110,15 +123,25 @@ function parseArgs(argv) {
110
123
  else if (arg === "--help" || arg === "-h") options.help = true;
111
124
  else if (arg === "--install-consumer-entrypoints") options.installConsumerEntrypoints = true;
112
125
  else if (arg === "--force") options.force = true;
126
+ else if (arg === "--dry-run") options.dryRun = true;
113
127
  else if (arg === "--workspace") options.workspace = rest[++index];
128
+ else if (arg === "--repo-url") options.repoUrl = rest[++index];
114
129
  else if (arg === "--config") options.config = rest[++index];
115
130
  else options._.push(arg);
116
131
  }
117
132
  return { command, options, rawArgs: rest };
118
133
  }
119
134
 
135
+ function assertNoUnknownCommandFlags(command, options) {
136
+ const unknownFlags = options._.filter((arg) => arg.startsWith("--"));
137
+ if (unknownFlags.length === 0) return;
138
+
139
+ const noun = unknownFlags.length === 1 ? "argument" : "arguments";
140
+ throw new Error(`Unknown ${noun} for structor ${command}: ${unknownFlags.join(", ")}`);
141
+ }
142
+
120
143
  function printHelp() {
121
- console.log(`Structor\n\nUsage:\n structor init [--workspace <path>] [--config <path>] [--yes]\n structor generate --config <path> [generator options]\n structor doctor\n\nCommands:\n init Guided local setup for a Structor workspace.\n generate Render a generated harness from an existing config.\n doctor Planned diagnostic and repair command.\n`);
144
+ console.log(`Structor\n\nUsage:\n structor init [--workspace <path>] [--config <path>] [--yes]\n structor generate --config <path> [generator options]\n structor contribute structor [--workspace <path>] [--repo-url <url-or-path>] [--yes] [--dry-run] [--force]\n structor doctor [--workspace <path>] [--config <path>]\n\nCommands:\n init Guided local setup for a Structor workspace.\n generate Render a generated harness from an existing config.\n contribute structor Create or refresh a local Structor contributor workspace.\n doctor Diagnose local Structor workspace drift without repairing files.\n`);
122
145
  }
123
146
 
124
147
  function runGenerator(args, cwd = process.cwd()) {
@@ -216,7 +239,52 @@ async function isDirectory(filePath) {
216
239
  }
217
240
  }
218
241
 
219
- function shouldExcludeCandidate(name) {
242
+ async function isEmptyDirectory(filePath) {
243
+ try {
244
+ return (await readdir(filePath)).length === 0;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ async function isUsableStructorCheckout(repoRoot) {
251
+ if (!(await isDirectory(repoRoot))) return false;
252
+ const packageJson = await maybeReadJson(path.join(repoRoot, "package.json"));
253
+ return (
254
+ packageJson?.name === "@structor-dev/cli" &&
255
+ await exists(path.join(repoRoot, "bin/structor.mjs")) &&
256
+ await exists(path.join(repoRoot, "scripts/setup-contributor.mjs")) &&
257
+ await exists(path.join(repoRoot, "contrib/self-harness/harness.config.json"))
258
+ );
259
+ }
260
+
261
+ export function contributorWorkspacePlan(options = {}, cwd = process.cwd()) {
262
+ const workspaceRoot = path.resolve(cwd, options.workspace ?? ".");
263
+ const sourceRoot = path.join(workspaceRoot, "structor");
264
+ const selfHarnessRoot = path.join(workspaceRoot, "structor-self");
265
+ return {
266
+ workspaceRoot,
267
+ sourceRoot,
268
+ selfHarnessRoot,
269
+ repoUrl: options.repoUrl ?? structorRepoUrlDefault,
270
+ };
271
+ }
272
+
273
+ function runCommand(command, args, cwd, stdio = "inherit") {
274
+ const result = spawnSync(command, args, {
275
+ cwd,
276
+ stdio,
277
+ encoding: stdio === "pipe" ? "utf8" : undefined,
278
+ });
279
+ if (result.error) throw result.error;
280
+ return result;
281
+ }
282
+
283
+ function commandText(command, args) {
284
+ return [command, ...args].join(" ");
285
+ }
286
+
287
+ export function shouldExcludeCandidate(name) {
220
288
  return (
221
289
  name.startsWith(".") ||
222
290
  name === "node_modules" ||
@@ -261,7 +329,7 @@ async function detectPackageManager(repoRoot) {
261
329
  return null;
262
330
  }
263
331
 
264
- function packageCommand(packageManager, scriptName) {
332
+ export function packageCommand(packageManager, scriptName) {
265
333
  if (packageManager === "yarn") return `yarn ${scriptName}`;
266
334
  if (scriptName === "test") return `${packageManager} test`;
267
335
  return `${packageManager} run ${scriptName}`;
@@ -297,7 +365,7 @@ async function inferValidation(repoRoot) {
297
365
  return validation;
298
366
  }
299
367
 
300
- function compactValidation(validation) {
368
+ export function compactValidation(validation) {
301
369
  return Object.fromEntries(Object.entries(validation).filter(([, value]) => value.trim() !== ""));
302
370
  }
303
371
 
@@ -323,12 +391,24 @@ async function collectConsumerDetails(rl, workspaceRoot, selectedCandidates) {
323
391
  return consumers;
324
392
  }
325
393
 
326
- async function promptManualConsumers(rl, workspaceRoot) {
394
+ async function promptManualConsumers(rl, workspaceRoot, outputPath) {
327
395
  const consumers = [];
328
396
  while (consumers.length === 0 || await askYesNo(rl, "Add another consumer repo?", false)) {
329
397
  section(`Consumer ${consumers.length + 1}`);
330
398
  const repoPath = await askLine(rl, "Path to consumer repo, relative to workspace", "./app");
331
399
  const absolutePath = path.resolve(workspaceRoot, repoPath);
400
+ try {
401
+ assertSafeConsumerPath({
402
+ consumerName: slugify(path.basename(absolutePath)),
403
+ consumerPath: repoPath,
404
+ workspaceRoot,
405
+ outputRoot: path.resolve(workspaceRoot, outputPath),
406
+ repoRoot: packageRoot,
407
+ });
408
+ } catch (error) {
409
+ warn(error instanceof Error ? error.message : String(error));
410
+ continue;
411
+ }
332
412
  if (!(await isDirectory(absolutePath))) {
333
413
  warn(`Path does not exist yet: ${absolutePath}`);
334
414
  if (!(await askYesNo(rl, "Use this path anyway?", false))) continue;
@@ -378,7 +458,7 @@ async function writeConfig(configPath, config) {
378
458
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
379
459
  }
380
460
 
381
- function nextValidationCommands(config) {
461
+ export function nextValidationCommands(config) {
382
462
  const commands = [
383
463
  `cd ${config.output.path}`,
384
464
  "node scripts/validate-governance.mjs",
@@ -389,6 +469,325 @@ function nextValidationCommands(config) {
389
469
  return commands;
390
470
  }
391
471
 
472
+ function cleanHarnessReference(rawReference) {
473
+ return rawReference.trim().replace(/^[`'"]+/, "").replace(/[`'",;:.)\]}]+$/, "");
474
+ }
475
+
476
+ function extractHarnessReferences(content, harnessRepoName) {
477
+ const escapedName = harnessRepoName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
478
+ const pattern = new RegExp("(?:\\.\\.?/|/)[^`'\"\\s)<\\]}]*(?:" + escapedName + ")[^`'\"\\s)<\\]}]*", "g");
479
+ return [...new Set((content.match(pattern) ?? []).map(cleanHarnessReference).filter(Boolean))];
480
+ }
481
+
482
+ function resolveHarnessReferenceTarget({ reference: rawReference, basePath, harnessRepoName }) {
483
+ const reference = cleanHarnessReference(rawReference);
484
+ const absoluteReference = path.isAbsolute(reference) ? path.resolve(reference) : path.resolve(basePath, reference);
485
+ const parts = absoluteReference.split(path.sep);
486
+ if (parts.lastIndexOf(harnessRepoName) === -1) return null;
487
+ return absoluteReference;
488
+ }
489
+
490
+ function resolveHarnessReferenceRoot({ reference: rawReference, basePath, harnessRepoName }) {
491
+ const target = resolveHarnessReferenceTarget({ reference: rawReference, basePath, harnessRepoName });
492
+ if (!target) return null;
493
+ const parts = target.split(path.sep);
494
+ const index = parts.lastIndexOf(harnessRepoName);
495
+ return parts.slice(0, index + 1).join(path.sep) || path.sep;
496
+ }
497
+
498
+ async function isFileTarget(targetPath) {
499
+ try {
500
+ return (await stat(targetPath)).isFile();
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+
506
+ async function canonicalExistingPath(targetPath) {
507
+ try {
508
+ return await realpath(targetPath);
509
+ } catch {
510
+ return path.resolve(targetPath);
511
+ }
512
+ }
513
+
514
+ function isWithinRoot(root, candidate) {
515
+ if (candidate === root) return true;
516
+ return candidate.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
517
+ }
518
+
519
+ async function validateHarnessReferences({
520
+ pointerPath,
521
+ pointerContent,
522
+ basePath,
523
+ expectedHarnessRoot,
524
+ harnessRepoName,
525
+ models,
526
+ requireHarnessReference = true,
527
+ }) {
528
+ const references = extractHarnessReferences(pointerContent, harnessRepoName);
529
+ if (references.length === 0) {
530
+ return requireHarnessReference
531
+ ? [`${pointerPath} does not contain a resolvable ${harnessRepoName} path.`]
532
+ : [];
533
+ }
534
+
535
+ const issues = [];
536
+ for (const reference of references) {
537
+ const target = resolveHarnessReferenceTarget({ reference, basePath, harnessRepoName });
538
+ const referenceRoot = resolveHarnessReferenceRoot({ reference, basePath, harnessRepoName });
539
+ if (!target || !referenceRoot) {
540
+ issues.push(`${pointerPath} does not contain a resolvable ${harnessRepoName} path.`);
541
+ continue;
542
+ }
543
+ const canonicalReferenceRoot = await canonicalExistingPath(referenceRoot);
544
+ const canonicalExpectedHarnessRoot = await canonicalExistingPath(expectedHarnessRoot);
545
+ if (canonicalReferenceRoot !== canonicalExpectedHarnessRoot) {
546
+ issues.push(`${pointerPath} points at ${referenceRoot} instead of ${expectedHarnessRoot}.`);
547
+ continue;
548
+ }
549
+
550
+ const relativeTarget = path.relative(referenceRoot, target).replaceAll(path.sep, "/");
551
+ if (!models.openai && relativeTarget === "AGENTS.md") {
552
+ issues.push(`${pointerPath} must not reference ${relativeTarget} when OpenAI support is disabled.`);
553
+ } else if (!models.anthropic && relativeTarget === "CLAUDE.md") {
554
+ issues.push(`${pointerPath} must not reference ${relativeTarget} when Anthropic support is disabled.`);
555
+ } else if (relativeTarget === "" || !(await isFileTarget(target))) {
556
+ issues.push(`${pointerPath} references missing generated-harness file ${relativeTarget || "."}.`);
557
+ } else {
558
+ // The lexical reference root can match while the target file resolves,
559
+ // via a symlink, to policy outside the harness. Require the canonical
560
+ // (realpath-resolved) target to stay under the canonical harness root.
561
+ const canonicalTarget = await canonicalExistingPath(target);
562
+ if (!isWithinRoot(canonicalExpectedHarnessRoot, canonicalTarget)) {
563
+ issues.push(`${pointerPath} resolves to ${canonicalTarget} outside the generated harness ${canonicalExpectedHarnessRoot}.`);
564
+ }
565
+ }
566
+ }
567
+ return issues;
568
+ }
569
+
570
+ async function readIfExists(filePath) {
571
+ if (!(await exists(filePath))) return null;
572
+ return readFile(filePath, "utf8");
573
+ }
574
+
575
+ async function collectEntrypointRoutingIssues({
576
+ label,
577
+ basePath,
578
+ entrypoints,
579
+ expectedHarnessRoot,
580
+ harnessRepoName,
581
+ models,
582
+ }) {
583
+ const issues = [];
584
+ for (const entrypoint of entrypoints) {
585
+ const pointerPath = path.join(basePath, entrypoint.path);
586
+ const pointerContent = await readIfExists(pointerPath);
587
+ if (pointerContent === null) {
588
+ issues.push(`${label}:${entrypoint.path} missing.`);
589
+ continue;
590
+ }
591
+ if (entrypoint.routing === "claude-memory") {
592
+ if (!pointerContent.includes("../CLAUDE.md")) {
593
+ issues.push(`${label}:${entrypoint.path} must route through ../CLAUDE.md.`);
594
+ }
595
+ const referenceIssues = await validateHarnessReferences({
596
+ pointerPath: `${label}:${entrypoint.path}`,
597
+ pointerContent,
598
+ basePath,
599
+ expectedHarnessRoot,
600
+ harnessRepoName,
601
+ models,
602
+ requireHarnessReference: false,
603
+ });
604
+ issues.push(...referenceIssues);
605
+ continue;
606
+ }
607
+ const referenceIssues = await validateHarnessReferences({
608
+ pointerPath: `${label}:${entrypoint.path}`,
609
+ pointerContent,
610
+ basePath,
611
+ expectedHarnessRoot,
612
+ harnessRepoName,
613
+ models,
614
+ });
615
+ issues.push(...referenceIssues);
616
+ }
617
+ return issues;
618
+ }
619
+
620
+ function printDoctorCheck(results, status, label, detail = "") {
621
+ results.push({ status, label, detail });
622
+ const renderedStatus =
623
+ status === "OK" ? color("green", "OK") :
624
+ status === "WARN" ? color("yellow", "WARN") :
625
+ color("red", "FAIL");
626
+ console.log(`${renderedStatus} ${label}${detail ? ` - ${detail}` : ""}`);
627
+ }
628
+
629
+ async function doctor(options) {
630
+ const results = [];
631
+ const workspaceRoot = path.resolve(options.workspace ?? process.cwd());
632
+ const configPath = path.resolve(workspaceRoot, options.config ?? configFileName);
633
+ section("Structor doctor");
634
+ note("Diagnosis only. No files will be repaired or written.");
635
+
636
+ let config = null;
637
+ if (await exists(configPath)) {
638
+ printDoctorCheck(results, "OK", "config file exists", configPath);
639
+ try {
640
+ config = await readJson(configPath);
641
+ printDoctorCheck(results, "OK", "config file parses");
642
+ } catch (error) {
643
+ printDoctorCheck(results, "FAIL", "config file parses", error instanceof Error ? error.message : String(error));
644
+ }
645
+ } else {
646
+ printDoctorCheck(results, "FAIL", "config file exists", configPath);
647
+ }
648
+
649
+ let resolvedConfig = null;
650
+ if (config) {
651
+ const shapeErrors = await validateConfigShape(config, configPath);
652
+ if (shapeErrors.length === 0) {
653
+ printDoctorCheck(results, "OK", "config shape is valid");
654
+ } else {
655
+ for (const error of shapeErrors) printDoctorCheck(results, "FAIL", "config shape is valid", error);
656
+ }
657
+
658
+ if (shapeErrors.length === 0) {
659
+ try {
660
+ resolvedConfig = await resolveHarnessConfig(config, {
661
+ label: configPath,
662
+ configPath,
663
+ outputPath: config.output.path,
664
+ });
665
+ printDoctorCheck(results, "OK", "output root is safe", resolvedConfig.outputRoot);
666
+ } catch (error) {
667
+ printDoctorCheck(results, "FAIL", "output root is safe", error instanceof Error ? error.message : String(error));
668
+ }
669
+ }
670
+ }
671
+
672
+ if (resolvedConfig) {
673
+ const { outputRoot, workspaceRoot: resolvedWorkspaceRoot, consumers, support } = resolvedConfig;
674
+ const settings = { models: config.models, clientSupport: support };
675
+ const harnessRepoName = config.project.harnessRepoName;
676
+ const repoRequiredFiles = requiredHarnessRepoFilesForWorkspaceCheck(settings);
677
+ const workspaceRequiredFiles = requiredWorkspaceFilesForWorkspaceCheck(settings);
678
+ const workspaceRoutingEntrypoints = workspaceEntrypointsForSettings(settings).filter(
679
+ (entrypoint) => entrypoint.routing !== "presence",
680
+ );
681
+ const consumerRoutingEntrypoints = consumerEntrypointsForSettings(settings);
682
+
683
+ if (path.basename(outputRoot) === harnessRepoName) {
684
+ printDoctorCheck(results, "OK", "generated harness folder name matches config", harnessRepoName);
685
+ } else {
686
+ printDoctorCheck(results, "FAIL", "generated harness folder name matches config", `expected ${harnessRepoName}, found ${path.basename(outputRoot)}`);
687
+ }
688
+
689
+ if (await isDirectory(outputRoot)) {
690
+ printDoctorCheck(results, "OK", "generated harness output directory exists", outputRoot);
691
+ } else {
692
+ printDoctorCheck(results, "FAIL", "generated harness output directory exists", outputRoot);
693
+ }
694
+
695
+ const missingRepoFiles = [];
696
+ for (const relativePath of repoRequiredFiles) {
697
+ if (!(await exists(path.join(outputRoot, relativePath)))) missingRepoFiles.push(relativePath);
698
+ }
699
+ if (missingRepoFiles.length === 0) {
700
+ printDoctorCheck(results, "OK", "generated harness required files exist");
701
+ } else {
702
+ for (const relativePath of missingRepoFiles) {
703
+ printDoctorCheck(results, "FAIL", "generated harness required file exists", relativePath);
704
+ }
705
+ }
706
+
707
+ const missingWorkspaceFiles = [];
708
+ for (const relativePath of workspaceRequiredFiles) {
709
+ if (!(await exists(path.join(resolvedWorkspaceRoot, relativePath)))) missingWorkspaceFiles.push(relativePath);
710
+ }
711
+ if (missingWorkspaceFiles.length === 0) {
712
+ printDoctorCheck(results, "OK", "workspace entrypoint files exist");
713
+ } else {
714
+ for (const relativePath of missingWorkspaceFiles) {
715
+ printDoctorCheck(results, "FAIL", "workspace entrypoint file exists", relativePath);
716
+ }
717
+ }
718
+
719
+ const workspaceRoutingIssues = await collectEntrypointRoutingIssues({
720
+ label: "workspace",
721
+ basePath: resolvedWorkspaceRoot,
722
+ entrypoints: workspaceRoutingEntrypoints,
723
+ expectedHarnessRoot: outputRoot,
724
+ harnessRepoName,
725
+ models: config.models,
726
+ });
727
+ if (workspaceRoutingIssues.length === 0) {
728
+ printDoctorCheck(results, "OK", "workspace pointer files route to generated harness");
729
+ } else {
730
+ for (const issue of workspaceRoutingIssues) printDoctorCheck(results, "FAIL", "workspace pointer file routes to generated harness", issue);
731
+ }
732
+
733
+ for (const consumer of consumers) {
734
+ const consumerRoot = consumer.root;
735
+ if (await isDirectory(consumerRoot)) {
736
+ if (await hasConsumerRepositorySignal(consumerRoot)) {
737
+ printDoctorCheck(results, "OK", `consumer repo exists: ${consumer.config.name}`, consumerRoot);
738
+ } else {
739
+ printDoctorCheck(results, "FAIL", `consumer repo exists: ${consumer.config.name}`, `missing repository signal at ${consumerRoot}`);
740
+ }
741
+ } else {
742
+ printDoctorCheck(results, "FAIL", `consumer repo exists: ${consumer.config.name}`, consumerRoot);
743
+ continue;
744
+ }
745
+
746
+ if (Object.values(consumer.config.validation ?? {}).some((value) => typeof value === "string" && value.trim() !== "")) {
747
+ printDoctorCheck(results, "OK", `consumer validation command documented: ${consumer.config.name}`);
748
+ } else {
749
+ printDoctorCheck(results, "WARN", `consumer validation command documented: ${consumer.config.name}`, "no validation commands configured");
750
+ }
751
+
752
+ const consumerRoutingIssues = await collectEntrypointRoutingIssues({
753
+ label: `consumer:${consumer.config.name}`,
754
+ basePath: consumerRoot,
755
+ entrypoints: consumerRoutingEntrypoints,
756
+ expectedHarnessRoot: outputRoot,
757
+ harnessRepoName,
758
+ models: config.models,
759
+ });
760
+ if (consumerRoutingIssues.length === 0) {
761
+ printDoctorCheck(results, "OK", `consumer pointer files route to generated harness: ${consumer.config.name}`);
762
+ } else {
763
+ for (const issue of consumerRoutingIssues) {
764
+ printDoctorCheck(results, "FAIL", `consumer pointer file routes to generated harness: ${consumer.config.name}`, issue);
765
+ }
766
+ }
767
+ }
768
+
769
+ const manifestPath = path.join(outputRoot, ".structor/manifest.json");
770
+ if (await exists(manifestPath)) {
771
+ try {
772
+ await readJson(manifestPath);
773
+ printDoctorCheck(results, "OK", "manifest is present and parses", manifestPath);
774
+ } catch (error) {
775
+ printDoctorCheck(results, "WARN", "manifest is present but does not parse", error instanceof Error ? error.message : String(error));
776
+ }
777
+ } else {
778
+ printDoctorCheck(results, "WARN", "manifest is present", "optional in doctor v1");
779
+ }
780
+ }
781
+
782
+ const failures = results.filter((result) => result.status === "FAIL").length;
783
+ const warnings = results.filter((result) => result.status === "WARN").length;
784
+ if (failures > 0) {
785
+ fail(`Structor doctor found ${failures} failure(s) and ${warnings} warning(s).`);
786
+ process.exit(1);
787
+ }
788
+ success(`Structor doctor passed with ${warnings} warning(s).`);
789
+ }
790
+
392
791
  function printNextSteps(config) {
393
792
  section("Next validation commands");
394
793
  note("Run these from the workspace after generation to prove harness policy and workspace routing are healthy.");
@@ -397,6 +796,128 @@ function printNextSteps(config) {
397
796
  }
398
797
  }
399
798
 
799
+ async function printContributorPlan(plan, options, sourceReady) {
800
+ section(options.dryRun ? "Contributor workspace preview" : "Contributor workspace");
801
+ console.log(`Workspace: ${plan.workspaceRoot}`);
802
+ console.log(`Structor source: ${plan.sourceRoot}`);
803
+ console.log(`Structor self-harness: ${plan.selfHarnessRoot}`);
804
+ console.log(`Repo URL: ${plan.repoUrl}`);
805
+ console.log(`Source checkout: ${sourceReady ? "reuse existing local checkout" : "clone required"}`);
806
+
807
+ section("Network reads");
808
+ if (sourceReady) {
809
+ console.log(" - none; existing local Structor checkout will be reused");
810
+ } else {
811
+ console.log(` - ${commandText("git", ["clone", plan.repoUrl, plan.sourceRoot])}`);
812
+ }
813
+
814
+ section("Local filesystem writes");
815
+ if (!sourceReady) console.log(` - create or use workspace folder ${plan.workspaceRoot}`);
816
+ if (!sourceReady) console.log(` - create Structor checkout ${plan.sourceRoot}`);
817
+ console.log(` - generate or refresh self-harness ${plan.selfHarnessRoot}`);
818
+ console.log(" - install missing source repo agent entrypoint pointers");
819
+ if (options.force) {
820
+ console.log(" - overwrite existing source repo agent entrypoint pointers because --force was provided");
821
+ } else {
822
+ console.log(" - skip existing source repo agent entrypoint pointers unless --force is provided");
823
+ }
824
+
825
+ section("Validation");
826
+ console.log(` - ${commandText(process.execPath, ["scripts/setup-contributor.mjs", ...(options.dryRun ? ["--dry-run"] : []), ...(options.force ? ["--force"] : [])])}`);
827
+ console.log(` - ${commandText(process.execPath, ["scripts/validate-governance.mjs"])} in ${plan.selfHarnessRoot}`);
828
+ console.log(` - ${commandText(process.execPath, ["scripts/check-workspace.mjs"])} in ${plan.selfHarnessRoot}`);
829
+ console.log(` - deferred source validation: cd ${plan.sourceRoot} && npm run validate`);
830
+ }
831
+
832
+ async function confirmContributorRun(options, plan, sourceReady) {
833
+ if (options.yes || options.dryRun) return true;
834
+ const rl = await createPrompt();
835
+ try {
836
+ await printContributorPlan(plan, options, sourceReady);
837
+ return await askYesNo(rl, "Continue with local writes and any required clone read?", false);
838
+ } finally {
839
+ rl.close();
840
+ }
841
+ }
842
+
843
+ async function ensureStructorCheckout(plan, options) {
844
+ const sourceReady = await isUsableStructorCheckout(plan.sourceRoot);
845
+ if (sourceReady) return { sourceReady: true, cloned: false };
846
+
847
+ if (await exists(plan.sourceRoot) && !(await isEmptyDirectory(plan.sourceRoot))) {
848
+ throw new Error(
849
+ `Existing ${plan.sourceRoot} is not a usable Structor checkout. Move it, choose a different --workspace, or provide a workspace with a valid structor checkout.`,
850
+ );
851
+ }
852
+
853
+ if (options.dryRun) return { sourceReady: false, cloned: false };
854
+
855
+ await mkdir(plan.workspaceRoot, { recursive: true });
856
+ section("Clone Structor source");
857
+ note("Network read only: this clones source code and does not authenticate, fork, push, open PRs, or mutate remotes.");
858
+ const clone = runCommand("git", ["clone", plan.repoUrl, plan.sourceRoot], process.cwd());
859
+ if (clone.status !== 0) throw new Error(`Clone failed: ${commandText("git", ["clone", plan.repoUrl, plan.sourceRoot])}`);
860
+ if (!(await isUsableStructorCheckout(plan.sourceRoot))) {
861
+ throw new Error(`Clone completed but ${plan.sourceRoot} is not a usable Structor checkout.`);
862
+ }
863
+ return { sourceReady: false, cloned: true };
864
+ }
865
+
866
+ function runContributorValidation(plan) {
867
+ section("Validate self-harness");
868
+ const validationCommands = [
869
+ [process.execPath, ["scripts/validate-governance.mjs"]],
870
+ [process.execPath, ["scripts/check-workspace.mjs"]],
871
+ ];
872
+ for (const [command, args] of validationCommands) {
873
+ console.log(`$ ${commandText(command, args)}`);
874
+ const result = runCommand(command, args, plan.selfHarnessRoot);
875
+ if (result.status !== 0) {
876
+ throw new Error(`Validation failed in ${plan.selfHarnessRoot}: ${commandText(command, args)}`);
877
+ }
878
+ }
879
+ }
880
+
881
+ function printContributorSummary(plan, setupArgs, validationRan) {
882
+ section("Structor contributor workspace ready");
883
+ console.log(`Workspace: ${plan.workspaceRoot}`);
884
+ console.log(`Structor source: ${plan.sourceRoot}`);
885
+ console.log(`Structor self-harness: ${plan.selfHarnessRoot}`);
886
+ console.log(`Contributor setup: ${commandText(process.execPath, setupArgs)}`);
887
+ console.log(`Validation: ${validationRan ? "passed" : "preview only; no validation commands were run"}`);
888
+
889
+ section("Next agent prompt");
890
+ console.log(`Work in ${plan.sourceRoot} using the sibling self-harness at ${plan.selfHarnessRoot}. Read AGENTS.md, ai/HUB.md, and the relevant issue, then make the smallest Structor change with validation evidence.`);
891
+ }
892
+
893
+ async function contributeStructor(options) {
894
+ if (options._.length !== 1) {
895
+ throw new Error("Usage: structor contribute structor [--workspace <path>] [--repo-url <url-or-path>] [--yes] [--dry-run] [--force]");
896
+ }
897
+ const plan = contributorWorkspacePlan(options);
898
+ const sourceReady = await isUsableStructorCheckout(plan.sourceRoot);
899
+ if (!(await confirmContributorRun(options, plan, sourceReady))) {
900
+ warn("Stopped before cloning, generating, or writing local files.");
901
+ return;
902
+ }
903
+
904
+ if (options.dryRun) {
905
+ await printContributorPlan(plan, options, sourceReady);
906
+ return;
907
+ }
908
+
909
+ await ensureStructorCheckout(plan, options);
910
+
911
+ section("Generate Structor self-harness");
912
+ const setupArgs = ["scripts/setup-contributor.mjs"];
913
+ if (options.force) setupArgs.push("--force");
914
+ const setup = runCommand(process.execPath, setupArgs, plan.sourceRoot);
915
+ if (setup.status !== 0) throw new Error(`Contributor setup failed: ${commandText(process.execPath, setupArgs)}`);
916
+
917
+ runContributorValidation(plan);
918
+ printContributorSummary(plan, setupArgs, true);
919
+ }
920
+
400
921
  async function init(options) {
401
922
  const rl = await createPrompt();
402
923
  try {
@@ -435,12 +956,8 @@ async function init(options) {
435
956
  ], defaultModelIndex);
436
957
 
437
958
  section("Customization");
438
- await askChoice(rl, "How much should Structor customize from consumer repos?", [
439
- { label: "Starter only", value: "starter", note: "available now" },
440
- { label: "Light scan", value: "starter", note: "coming soon" },
441
- { label: "Deep scan", value: "starter", note: "coming soon" },
442
- ]);
443
959
  note("Starter only creates generic harness content. It does not infer real contracts or coding conventions.");
960
+ note("Light Scan and Deep Scan are planned future opt-in Consumer Repo Scan modes.");
444
961
 
445
962
  section("Consumer repos");
446
963
  note("For best results, run Structor from the workspace folder that contains your consumer repos as siblings.");
@@ -463,7 +980,7 @@ async function init(options) {
463
980
  consumers = await collectConsumerDetails(rl, workspaceRoot, selected);
464
981
  } else {
465
982
  warn("No obvious sibling consumer repos found.");
466
- consumers = await promptManualConsumers(rl, workspaceRoot);
983
+ consumers = await promptManualConsumers(rl, workspaceRoot, outputPath);
467
984
  }
468
985
  }
469
986
 
@@ -512,7 +1029,7 @@ async function init(options) {
512
1029
  printCommandOutput(dryRun);
513
1030
  if (dryRun.status !== 0) throw new Error("Generator dry-run failed.");
514
1031
 
515
- const apply = options.yes || await askYesNo(rl, "Generate harness now?", false);
1032
+ const apply = options.yes || await askYesNo(rl, "Generate harness now?", true);
516
1033
  if (!apply) {
517
1034
  warn("Stopped after dry-run preview.");
518
1035
  printNextSteps(config);
@@ -551,6 +1068,7 @@ async function main() {
551
1068
  return;
552
1069
  }
553
1070
  if (command === "init") {
1071
+ assertNoUnknownCommandFlags(command, options);
554
1072
  await init(options);
555
1073
  return;
556
1074
  }
@@ -558,19 +1076,33 @@ async function main() {
558
1076
  passthroughGenerate(rawArgs);
559
1077
  return;
560
1078
  }
1079
+ if (command === "contribute") {
1080
+ const [target] = options._;
1081
+ if (target !== "structor") {
1082
+ throw new Error("Unknown contribute target. Supported target: structor");
1083
+ }
1084
+ await contributeStructor(options);
1085
+ return;
1086
+ }
561
1087
  if (command === "doctor") {
562
- note("structor doctor is planned but not implemented yet.");
563
- note("It will diagnose and repair drift in an existing Structor workspace:");
564
- note(" - stale or missing consumer entrypoints");
565
- note(" - moved harness or consumer folders");
566
- note(" - unsafe output paths");
567
- note("Track progress: docs/issues/0001-structor-doctor.md");
568
- process.exit(1);
1088
+ assertNoUnknownCommandFlags(command, options);
1089
+ await doctor(options);
1090
+ return;
569
1091
  }
570
1092
  throw new Error(`Unknown command: ${command}`);
571
1093
  }
572
1094
 
573
- main().catch((error) => {
574
- fail(error instanceof Error ? error.message : String(error));
575
- process.exit(1);
576
- });
1095
+ async function isDirectCliInvocation() {
1096
+ if (!process.argv[1]) return false;
1097
+
1098
+ const invokedPath = await realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
1099
+ const modulePath = await realpath(fileURLToPath(import.meta.url)).catch(() => fileURLToPath(import.meta.url));
1100
+ return pathToFileURL(invokedPath).href === pathToFileURL(modulePath).href;
1101
+ }
1102
+
1103
+ if (await isDirectCliInvocation()) {
1104
+ main().catch((error) => {
1105
+ fail(error instanceof Error ? error.message : String(error));
1106
+ process.exit(1);
1107
+ });
1108
+ }