@structor-dev/cli 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -55,10 +55,11 @@ During local development from a clone of this repo, use
55
55
  `node ./structor/bin/structor.mjs init` from the parent workspace instead.
56
56
 
57
57
  `init` is local-only and deterministic. It detects sibling repos, asks a few
58
- questions, writes `harness.config.json` only after confirmation, previews every
59
- generated file with a dry run, and generates nothing until you confirm. No
60
- network calls, no LLM calls, no telemetry, no package installs, and no remote
61
- service mutation.
58
+ questions, previews the full setup transaction with a dry run, persists
59
+ `harness.config.json` inside the generated harness only after confirmation, and
60
+ does not report success until consumer entrypoints, workspace entrypoints, and
61
+ completion gates have passed. No network calls, no LLM calls, no telemetry, no
62
+ package installs, and no remote service mutation.
62
63
 
63
64
  `structor init` remains the normal setup flow for users creating generated
64
65
  harnesses for their own target repositories. Contributing to Structor itself is
@@ -222,9 +223,9 @@ These boundaries are intentional in the current template:
222
223
  The supported happy path is:
223
224
 
224
225
  1. Clone this template repo into the same workspace folder as the consumer repos.
225
- 2. Generate the project harness as a sibling of the consumer repos.
226
- 3. Install consumer repo entrypoints during initialization.
227
- 4. Run the generated harness workspace bootstrap script.
226
+ 2. Run `structor init` from the workspace folder.
227
+ 3. Let Structor generate the project harness as a sibling of the consumer repos.
228
+ 4. Let Structor install or verify consumer and workspace entrypoints before success.
228
229
  5. Start Codex or Claude Code from the workspace, generated harness, or a
229
230
  bootstrapped consumer repo.
230
231
 
@@ -241,10 +242,11 @@ workspace/
241
242
 
242
243
  With that layout, the current flow can bootstrap consumer repos out of the box
243
244
  when their agent pointer files are missing. For safety, existing consumer
244
- `AGENTS.md`, `CLAUDE.md`, and `.claude/CLAUDE.md` files are skipped unless
245
- `--force` is explicitly passed. If you generate the harness somewhere else,
246
- move or copy it into the sibling workspace layout before running the generated
247
- workspace bootstrap scripts.
245
+ `AGENTS.md`, `CLAUDE.md`, and `.claude/CLAUDE.md` files must already match the
246
+ expected Structor-managed pointer surfaces or `init` fails unless `--force` is
247
+ explicitly passed. If you generate the harness somewhere else, move or copy it
248
+ into the sibling workspace layout before running the generated workspace
249
+ bootstrap scripts.
248
250
 
249
251
  ## Agent-Assisted Manual Setup
250
252
 
package/bin/structor.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "node:child_process";
4
- import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
+ import { access, mkdir, readdir, readFile, realpath, rm, 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";
@@ -13,6 +13,15 @@ import {
13
13
  resolveHarnessConfig,
14
14
  validateConfigShape,
15
15
  } from "../scripts/lib.mjs";
16
+ import {
17
+ generateHarness,
18
+ installConsumerEntrypoints,
19
+ render,
20
+ } from "../scripts/init-harness.mjs";
21
+ import {
22
+ consumerEntrypointValues,
23
+ harnessTemplateValues,
24
+ } from "../scripts/rendered-config.mjs";
16
25
  import {
17
26
  consumerEntrypointsForSettings,
18
27
  requiredHarnessRepoFilesForWorkspaceCheck,
@@ -453,11 +462,67 @@ async function loadExistingConfig(configPath) {
453
462
  }
454
463
  }
455
464
 
465
+ async function discoverWorkspaceConfigPath(workspaceRoot, explicitConfigPath = null) {
466
+ if (explicitConfigPath) return path.resolve(workspaceRoot, explicitConfigPath);
467
+
468
+ const workspaceConfigPath = path.join(workspaceRoot, configFileName);
469
+ const matches = [];
470
+ const skipDirectoryNames = new Set([".git", "node_modules"]);
471
+
472
+ async function visitDirectory(directoryPath, depth = 0) {
473
+ if (depth > 4) return;
474
+
475
+ const candidatePath = path.join(directoryPath, configFileName);
476
+ const candidate = await maybeReadJson(candidatePath);
477
+ const resolvedWorkspaceRoot = candidate?.workspace?.root
478
+ ? path.resolve(path.dirname(candidatePath), candidate.workspace.root)
479
+ : null;
480
+ if (resolvedWorkspaceRoot === workspaceRoot) {
481
+ matches.push(candidatePath);
482
+ }
483
+
484
+ const entries = await readdir(directoryPath, { withFileTypes: true });
485
+ for (const entry of entries) {
486
+ if (!entry.isDirectory() || entry.name.startsWith(".") || skipDirectoryNames.has(entry.name)) continue;
487
+ await visitDirectory(path.join(directoryPath, entry.name), depth + 1);
488
+ }
489
+ }
490
+
491
+ await visitDirectory(workspaceRoot);
492
+ return matches.length === 1 ? matches[0] : workspaceConfigPath;
493
+ }
494
+
456
495
  async function writeConfig(configPath, config) {
457
496
  await mkdir(path.dirname(configPath), { recursive: true });
458
497
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
459
498
  }
460
499
 
500
+ function configContent(config) {
501
+ return `${JSON.stringify(config, null, 2)}\n`;
502
+ }
503
+
504
+ function durableConfigPathFor(workspaceRoot, outputPath) {
505
+ return path.join(path.resolve(workspaceRoot, outputPath), configFileName);
506
+ }
507
+
508
+ function initConfigWithWorkspaceRoot(config, workspaceRoot) {
509
+ const configPath = durableConfigPathFor(workspaceRoot, config.output.path);
510
+ return {
511
+ workspace: {
512
+ root: relativeFrom(path.dirname(configPath), workspaceRoot),
513
+ },
514
+ ...config,
515
+ };
516
+ }
517
+
518
+ function initCompletionCommands(harnessRoot) {
519
+ return [
520
+ commandText(process.execPath, ["scripts/validate-governance.mjs"]),
521
+ commandText(process.execPath, ["scripts/check-workspace.mjs"]),
522
+ `Config: ${path.join(harnessRoot, configFileName)}`,
523
+ ];
524
+ }
525
+
461
526
  export function nextValidationCommands(config) {
462
527
  const commands = [
463
528
  `cd ${config.output.path}`,
@@ -629,7 +694,7 @@ function printDoctorCheck(results, status, label, detail = "") {
629
694
  async function doctor(options) {
630
695
  const results = [];
631
696
  const workspaceRoot = path.resolve(options.workspace ?? process.cwd());
632
- const configPath = path.resolve(workspaceRoot, options.config ?? configFileName);
697
+ const configPath = await discoverWorkspaceConfigPath(workspaceRoot, options.config);
633
698
  section("Structor doctor");
634
699
  note("Diagnosis only. No files will be repaired or written.");
635
700
 
@@ -796,6 +861,162 @@ function printNextSteps(config) {
796
861
  }
797
862
  }
798
863
 
864
+ function printSetupTransactionPreview(config, configPath) {
865
+ const settings = {
866
+ models: config.models,
867
+ clientSupport: {
868
+ codexHooks: config.clientSupport?.codex?.hooks ?? config.models.openai,
869
+ claudeRules: config.clientSupport?.claude?.rules ?? config.models.anthropic,
870
+ claudeHooks: false,
871
+ claudeSkills: false,
872
+ },
873
+ };
874
+
875
+ section("Setup transaction preview");
876
+ console.log(`Durable config: ${configPath}`);
877
+ console.log(`Generated harness: ${config.output.path}`);
878
+ console.log("Consumer entrypoints:");
879
+ for (const consumer of config.consumers) {
880
+ for (const entrypoint of consumerEntrypointsForSettings(settings)) {
881
+ console.log(` - ${consumer.path}/${entrypoint.path}`);
882
+ }
883
+ }
884
+ console.log("Workspace entrypoints:");
885
+ for (const entrypoint of workspaceEntrypointsForSettings(settings)) {
886
+ console.log(` - ${entrypoint.path}`);
887
+ }
888
+ console.log("Completion gates:");
889
+ console.log(" - node scripts/validate-governance.mjs");
890
+ console.log(" - node scripts/check-workspace.mjs");
891
+ }
892
+
893
+ async function runGeneratedNodeScript({ harnessRoot, relativeScriptPath, args = [], failureLabel }) {
894
+ const result = spawnSync(process.execPath, [relativeScriptPath, ...args], {
895
+ cwd: harnessRoot,
896
+ encoding: "utf8",
897
+ stdio: ["ignore", "pipe", "pipe"],
898
+ });
899
+ printCommandOutput(result);
900
+ if (result.status !== 0) {
901
+ throw new Error(failureLabel);
902
+ }
903
+ }
904
+
905
+ function assertGeneratedScriptsReady(generatedFiles, scriptPaths) {
906
+ const filesByPath = new Map(generatedFiles.map((file) => [file.targetRelative, file]));
907
+ const notReady = [];
908
+ for (const scriptPath of scriptPaths) {
909
+ const file = filesByPath.get(scriptPath);
910
+ if (!file || file.action === "skipped" || !file.rendered) {
911
+ notReady.push(scriptPath);
912
+ }
913
+ }
914
+
915
+ if (notReady.length > 0) {
916
+ throw new Error(
917
+ `Generated setup scripts were not refreshed or verified:\n${notReady.map((item) => `- ${item}`).join("\n")}\nInspect the existing files and re-run with --force if they should be replaced.`,
918
+ );
919
+ }
920
+ }
921
+
922
+ async function assertNoEntrypointConflicts({ config, resolvedConfig, harnessRoot, force }) {
923
+ if (force) return;
924
+
925
+ const settings = { models: config.models, clientSupport: resolvedConfig.support };
926
+ const conflicts = [];
927
+ const templateWorkspaceRoot = config.workspace?.root
928
+ ? path.resolve(harnessRoot, config.workspace.root)
929
+ : path.dirname(harnessRoot);
930
+ const harnessValues = harnessTemplateValues(
931
+ config,
932
+ resolvedConfig.support,
933
+ resolvedConfig.consumers,
934
+ harnessRoot,
935
+ templateWorkspaceRoot,
936
+ );
937
+
938
+ for (const entrypoint of workspaceEntrypointsForSettings(settings)) {
939
+ const targetPath = path.join(resolvedConfig.workspaceRoot, entrypoint.path);
940
+ if (!(await exists(targetPath))) continue;
941
+
942
+ const templatePath = path.join(packageRoot, "template", entrypoint.template);
943
+ const [actual, template] = await Promise.all([
944
+ readFile(targetPath, "utf8"),
945
+ readFile(templatePath, "utf8"),
946
+ ]);
947
+ const expected = render(template, harnessValues);
948
+ if (actual !== expected) {
949
+ conflicts.push(`workspace:${entrypoint.path}`);
950
+ }
951
+ }
952
+
953
+ for (const resolvedConsumer of resolvedConfig.consumers) {
954
+ const consumer = resolvedConsumer.config;
955
+ const consumerRoot = resolvedConsumer.confirmedRoot ?? resolvedConsumer.root;
956
+ const harnessRelativePath = path.relative(consumerRoot, harnessRoot).replaceAll(path.sep, "/") || ".";
957
+ const values = consumerEntrypointValues(config, consumer, harnessRelativePath);
958
+
959
+ for (const entrypoint of consumerEntrypointsForSettings(settings)) {
960
+ const targetPath = path.join(consumerRoot, entrypoint.path);
961
+ if (!(await exists(targetPath))) continue;
962
+
963
+ const templatePath = path.join(packageRoot, "template", entrypoint.template);
964
+ const [actual, template] = await Promise.all([
965
+ readFile(targetPath, "utf8"),
966
+ readFile(templatePath, "utf8"),
967
+ ]);
968
+ const expected = render(template, values);
969
+ if (actual !== expected) {
970
+ conflicts.push(`consumer:${consumer.name}:${entrypoint.path}`);
971
+ }
972
+ }
973
+ }
974
+
975
+ if (conflicts.length > 0) {
976
+ throw new Error(
977
+ `Entrypoint conflicts detected before bootstrap:\n${conflicts.map((item) => `- ${item}`).join("\n")}\nRe-run with --force to overwrite known Structor pointer surfaces.`,
978
+ );
979
+ }
980
+ }
981
+
982
+ async function removeEmptyParents(startPath, stopPath) {
983
+ let current = path.dirname(startPath);
984
+ const resolvedStop = path.resolve(stopPath);
985
+ while (current.startsWith(resolvedStop) && current !== resolvedStop) {
986
+ try {
987
+ await rm(current);
988
+ } catch (error) {
989
+ if (error?.code === "ENOTEMPTY" || error?.code === "ENOENT") return;
990
+ throw error;
991
+ }
992
+ current = path.dirname(current);
993
+ }
994
+ }
995
+
996
+ async function cleanupFailedInit({
997
+ harnessRoot,
998
+ harnessRootExisted,
999
+ workspaceRoot,
1000
+ workspaceCreatedPaths,
1001
+ consumerCreatedPaths,
1002
+ harnessCreatedPaths,
1003
+ }) {
1004
+ for (const targetPath of [...workspaceCreatedPaths, ...consumerCreatedPaths]) {
1005
+ await rm(targetPath, { force: true });
1006
+ await removeEmptyParents(targetPath, workspaceRoot);
1007
+ }
1008
+
1009
+ if (!harnessRootExisted) {
1010
+ await rm(harnessRoot, { recursive: true, force: true });
1011
+ return;
1012
+ }
1013
+
1014
+ for (const targetPath of harnessCreatedPaths) {
1015
+ await rm(targetPath, { force: true });
1016
+ await removeEmptyParents(targetPath, harnessRoot);
1017
+ }
1018
+ }
1019
+
799
1020
  async function printContributorPlan(plan, options, sourceReady) {
800
1021
  section(options.dryRun ? "Contributor workspace preview" : "Contributor workspace");
801
1022
  console.log(`Workspace: ${plan.workspaceRoot}`);
@@ -926,11 +1147,11 @@ async function init(options) {
926
1147
 
927
1148
  const workspaceDefault = options.workspace ? path.resolve(options.workspace) : process.cwd();
928
1149
  const workspaceRoot = path.resolve(await askLine(rl, "Workspace folder", workspaceDefault));
929
- const configPath = path.resolve(workspaceRoot, options.config ?? configFileName);
930
- const existingConfig = await loadExistingConfig(configPath);
1150
+ const legacyConfigPath = await discoverWorkspaceConfigPath(workspaceRoot, options.config);
1151
+ const existingConfig = await loadExistingConfig(legacyConfigPath);
931
1152
  let startingConfig = null;
932
1153
  if (existingConfig) {
933
- printConfigSummary(existingConfig, configPath);
1154
+ printConfigSummary(existingConfig, legacyConfigPath);
934
1155
  if (await askYesNo(rl, "Use this existing config as the starting point?", true)) {
935
1156
  startingConfig = existingConfig;
936
1157
  } else {
@@ -1010,47 +1231,130 @@ async function init(options) {
1010
1231
  consumers,
1011
1232
  };
1012
1233
 
1013
- printConfigSummary(config, configPath);
1234
+ const initConfig = initConfigWithWorkspaceRoot(config, workspaceRoot);
1235
+ const configPath = durableConfigPathFor(workspaceRoot, initConfig.output.path);
1236
+ printConfigSummary(initConfig, configPath);
1014
1237
  warnIfOutputIsNotWorkspaceChild(workspaceRoot, config.output.path);
1015
- note("harness.config.json is Structor's project-specific input: project facts, output path, agent clients, consumer repos, and validation commands.");
1016
- const canWriteConfig = existingConfig
1017
- ? await askYesNo(rl, "Replace existing harness.config.json with this config?", false)
1018
- : await askYesNo(rl, "Write harness.config.json?", true);
1019
- if (!canWriteConfig) {
1020
- warn("Stopped before writing config.");
1238
+ note("harness.config.json will be persisted inside the generated harness so init can finish with a fully bootstrapped workspace.");
1239
+ printSetupTransactionPreview(initConfig, configPath);
1240
+ const canContinue = existingConfig
1241
+ ? await askYesNo(rl, "Replace the generated harness config with this setup?", false)
1242
+ : await askYesNo(rl, "Continue with this setup?", true);
1243
+ if (!canContinue) {
1244
+ warn("Stopped before generation.");
1021
1245
  return;
1022
1246
  }
1023
- await writeConfig(configPath, config);
1024
- success(`Wrote ${configPath}`);
1025
1247
 
1026
1248
  section("Dry-run preview");
1027
- note("The initializer dry-run renders the plan without writing harness or consumer files.");
1028
- const dryRun = runGenerator(["--config", configPath, "--dry-run"], workspaceRoot);
1029
- printCommandOutput(dryRun);
1030
- if (dryRun.status !== 0) throw new Error("Generator dry-run failed.");
1249
+ note("The initializer dry-run renders the generated harness plan before any files are written.");
1250
+ const renderedConfig = configContent(initConfig);
1251
+ const dryRunGenerated = await generateHarness(initConfig, {
1252
+ configPath,
1253
+ configContent: renderedConfig,
1254
+ requireExistingConsumers: true,
1255
+ dryRun: true,
1256
+ });
1031
1257
 
1032
1258
  const apply = options.yes || await askYesNo(rl, "Generate harness now?", true);
1033
1259
  if (!apply) {
1034
1260
  warn("Stopped after dry-run preview.");
1035
- printNextSteps(config);
1036
1261
  return;
1037
1262
  }
1038
1263
 
1039
- const generateArgs = ["--config", configPath];
1040
- if (options.force) generateArgs.push("--force");
1041
- const installEntrypoints = options.installConsumerEntrypoints || await askYesNo(
1042
- rl,
1043
- "Install consumer entrypoint pointer files? These are thin AGENTS.md/CLAUDE.md files that route agents to the generated Structor repo.",
1044
- true,
1045
- );
1046
- if (installEntrypoints) generateArgs.push("--install-consumer-entrypoints");
1047
-
1048
1264
  section("Generate");
1049
- const result = runGenerator(generateArgs, workspaceRoot);
1050
- printCommandOutput(result);
1051
- if (result.status !== 0) throw new Error("Generation failed.");
1265
+ const harnessRoot = path.dirname(configPath);
1266
+ const harnessRootExisted = await exists(harnessRoot);
1267
+ const workspaceCreatedPaths = [];
1268
+ const consumerCreatedPaths = [];
1269
+ const harnessCreatedPaths = [];
1270
+
1271
+ try {
1272
+ await assertNoEntrypointConflicts({
1273
+ config: initConfig,
1274
+ resolvedConfig: dryRunGenerated.resolvedConfig,
1275
+ harnessRoot,
1276
+ force: options.force,
1277
+ });
1278
+
1279
+ const generated = await generateHarness(initConfig, {
1280
+ configPath,
1281
+ configContent: renderedConfig,
1282
+ requireExistingConsumers: true,
1283
+ force: options.force,
1284
+ dryRun: false,
1285
+ });
1286
+ harnessCreatedPaths.push(
1287
+ ...generated.generatedFiles
1288
+ .filter((file) => file.action === "created")
1289
+ .map((file) => file.targetPath),
1290
+ );
1291
+ if (generated.manifestFile?.action === "created") {
1292
+ harnessCreatedPaths.push(generated.manifestFile.targetPath);
1293
+ }
1294
+ assertGeneratedScriptsReady(generated.generatedFiles, [
1295
+ "scripts/bootstrap-workspace.mjs",
1296
+ "scripts/validate-governance.mjs",
1297
+ "scripts/check-workspace.mjs",
1298
+ ]);
1299
+
1300
+ const durableConfigExisted = await exists(configPath);
1301
+ await writeConfig(configPath, initConfig);
1302
+ success(`${durableConfigExisted ? "Updated" : "Wrote"} ${configPath}`);
1303
+ if (!durableConfigExisted) harnessCreatedPaths.push(configPath);
1304
+
1305
+ const consumerEntrypoints = await installConsumerEntrypoints(generated.resolvedConfig, {
1306
+ dryRun: false,
1307
+ force: options.force,
1308
+ });
1309
+ consumerCreatedPaths.push(
1310
+ ...consumerEntrypoints
1311
+ .filter((entrypoint) => entrypoint.action === "created")
1312
+ .map((entrypoint) => path.join(generated.resolvedConfig.workspaceRoot, entrypoint.consumerPath, entrypoint.path)),
1313
+ );
1314
+
1315
+ const settings = { models: initConfig.models, clientSupport: generated.resolvedConfig.support };
1316
+ for (const entrypoint of workspaceEntrypointsForSettings(settings)) {
1317
+ const targetPath = path.join(generated.resolvedConfig.workspaceRoot, entrypoint.path);
1318
+ if (!(await exists(targetPath))) workspaceCreatedPaths.push(targetPath);
1319
+ }
1320
+
1321
+ section("Workspace bootstrap");
1322
+ await runGeneratedNodeScript({
1323
+ harnessRoot,
1324
+ relativeScriptPath: "scripts/bootstrap-workspace.mjs",
1325
+ args: options.force ? ["--force"] : [],
1326
+ failureLabel: "Workspace bootstrap failed.",
1327
+ });
1328
+
1329
+ section("Completion gates");
1330
+ await runGeneratedNodeScript({
1331
+ harnessRoot,
1332
+ relativeScriptPath: "scripts/validate-governance.mjs",
1333
+ failureLabel: "Generated governance validation failed.",
1334
+ });
1335
+ await runGeneratedNodeScript({
1336
+ harnessRoot,
1337
+ relativeScriptPath: "scripts/check-workspace.mjs",
1338
+ failureLabel: "Workspace completion check failed.",
1339
+ });
1340
+ } catch (error) {
1341
+ await cleanupFailedInit({
1342
+ harnessRoot,
1343
+ harnessRootExisted,
1344
+ workspaceRoot,
1345
+ workspaceCreatedPaths,
1346
+ consumerCreatedPaths,
1347
+ harnessCreatedPaths,
1348
+ });
1349
+ throw error;
1350
+ }
1351
+
1052
1352
  success("Structor setup complete.");
1053
- printNextSteps(config);
1353
+ section("Setup ready");
1354
+ note("No post-success bootstrap steps are required.");
1355
+ for (const command of initCompletionCommands(harnessRoot)) {
1356
+ console.log(` ${command}`);
1357
+ }
1054
1358
  } finally {
1055
1359
  rl.close();
1056
1360
  }
package/docs/INIT.md CHANGED
@@ -33,10 +33,12 @@ The default first-run path is:
33
33
  1. Run `npx @structor-dev/cli init` from the parent workspace folder.
34
34
  2. Confirm the workspace, project name, generated harness path, agent clients,
35
35
  and consumer repos.
36
- 3. Let Structor write `harness.config.json` only after reviewing the summary.
37
- 4. Review the dry-run preview of generated harness and consumer pointer files.
36
+ 3. Review the setup summary, including the durable harness-local
37
+ `harness.config.json` path, entrypoint writes, and completion gates.
38
+ 4. Review the dry-run preview of the generated harness plan.
38
39
  5. Confirm generation only if the preview is correct.
39
- 6. Run the next validation commands printed by the CLI.
40
+ 6. Let Structor install or verify consumer and workspace entrypoints, then run
41
+ generated governance and workspace completion gates before success.
40
42
 
41
43
  During development from this repository, the equivalent local command is
42
44
  `npm run init -- --workspace ..`.
@@ -59,13 +61,16 @@ service; generated files remain local until you review and commit them.
59
61
 
60
62
  Only after confirmation, it can write:
61
63
 
62
- - `harness.config.json` in the selected workspace.
64
+ - `harness.config.json` inside the generated harness.
63
65
  - A generated Structor repo at the configured `output.path`.
64
- - Optional consumer entrypoint pointer files: `AGENTS.md`, `CLAUDE.md`, and
65
- `.claude/CLAUDE.md`.
66
+ - Required consumer entrypoint pointer files: `AGENTS.md`, `CLAUDE.md`, and
67
+ `.claude/CLAUDE.md` when the selected model support enables them.
68
+ - Required workspace entrypoint pointer files owned by the generated harness
69
+ bootstrap contract.
66
70
 
67
- Existing generated harness files and consumer entrypoints are skipped by the
68
- underlying generator unless the user passes `--force`.
71
+ Existing generated harness files and known Structor-managed pointer surfaces are
72
+ updated only when they already match the expected content or the user passes
73
+ `--force`. Conflicting user-owned or stale pointer files block setup.
69
74
 
70
75
  Consumer entrypoints are thin pointer files. They route Codex and Claude Code
71
76
  back to the generated harness; they are not copies of the canonical harness
@@ -118,14 +123,17 @@ implementation.
118
123
 
119
124
  `harness.config.json` is Structor's project-specific input file. It records:
120
125
 
126
+ - workspace root semantics for workspace-relative topology paths when the
127
+ config lives inside the generated harness
121
128
  - project name, slug, and generated repo name
122
129
  - output path
123
130
  - Codex and Claude support flags
124
131
  - consumer repo paths, purposes, and validation commands
125
132
 
126
- Consumer repo paths are workspace-relative. The generator rejects absolute
127
- consumer paths, `..` traversal, symlinked consumer paths, and entrypoint writes
128
- to directories that do not look like repositories.
133
+ Consumer repo paths and the durable init `output.path` remain workspace-relative
134
+ even when the config is stored inside the generated harness. The generator
135
+ rejects absolute consumer paths, `..` traversal, symlinked consumer paths, and
136
+ entrypoint writes to directories that do not look like repositories.
129
137
 
130
138
  `structor generate --config harness.config.json` uses this file to render the
131
139
  generated harness deterministically.
@@ -0,0 +1,42 @@
1
+ # ADR 0002: Store Harness Configuration In Generated Harness
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ `structor init` used to persist `harness.config.json` at the workspace root,
10
+ generate the harness, and then print manual bootstrap commands. That left two
11
+ problems:
12
+
13
+ - setup could report success before workspace entrypoints were actually
14
+ installed and verified
15
+ - a loose workspace-root config was fragile when the parent workspace was not a
16
+ repository and the generated harness was the durable artifact users kept
17
+
18
+ We also needed a way for paths such as `output.path` and consumer repo paths to
19
+ stay workspace-relative after moving the durable config into the generated
20
+ harness.
21
+
22
+ ## Decision
23
+
24
+ - New `structor init` runs persist `harness.config.json` inside the generated
25
+ harness repo.
26
+ - Init-authored configs include explicit `workspace.root` semantics so
27
+ workspace-relative topology paths keep resolving correctly even though the
28
+ config file now lives inside the harness repo.
29
+ - `structor init` does not print `Structor setup complete.` until consumer
30
+ entrypoints, workspace entrypoints, `node scripts/validate-governance.mjs`,
31
+ and `node scripts/check-workspace.mjs` have completed successfully.
32
+ - Existing manual `structor generate --config <path>` flows remain supported for
33
+ workspace-root configs that do not declare `workspace.root`.
34
+
35
+ ## Consequences
36
+
37
+ - The generated harness becomes the durable home for both policy and the init
38
+ recipe that produced it.
39
+ - Failed init attempts can clean up files created during that same setup
40
+ transaction without depending on a loose workspace-root config.
41
+ - Tooling that looks for a config from the workspace should prefer the explicit
42
+ harness-local config when it is the only unambiguous match.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structor-dev/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Harness-engineering toolkit that generates repository-local AI engineering harnesses for consumer repos.",
5
5
  "license": "MIT",
6
6
  "author": "Nicolay Camacho",
@@ -6,6 +6,14 @@
6
6
  "required": ["project", "output", "models", "consumers"],
7
7
  "properties": {
8
8
  "$schema": { "type": "string" },
9
+ "workspace": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "required": ["root"],
13
+ "properties": {
14
+ "root": { "type": "string", "minLength": 1 }
15
+ }
16
+ },
9
17
  "project": {
10
18
  "type": "object",
11
19
  "additionalProperties": false,
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import path from "node:path";
@@ -109,6 +109,26 @@ async function collectTemplateFiles() {
109
109
  return files.sort();
110
110
  }
111
111
 
112
+ async function collectExistingFiles(basePath) {
113
+ const files = new Set();
114
+ if (!(await exists(basePath))) return files;
115
+
116
+ async function walk(currentPath) {
117
+ const entries = await readdir(currentPath, { withFileTypes: true });
118
+ for (const entry of entries) {
119
+ const absolute = path.join(currentPath, entry.name);
120
+ if (entry.isDirectory()) {
121
+ await walk(absolute);
122
+ } else if (entry.isFile()) {
123
+ files.add(absolute);
124
+ }
125
+ }
126
+ }
127
+
128
+ await walk(basePath);
129
+ return files;
130
+ }
131
+
112
132
  async function generatedScriptHashes(templateFiles, config, values) {
113
133
  const hashes = {};
114
134
  const trustedScriptTemplates = new Set(trustedGeneratedScriptTemplatesForSettings(config));
@@ -137,6 +157,11 @@ export async function writeRenderedFile(sourceRelative, targetRoot, values, opti
137
157
  }
138
158
 
139
159
  if ((await exists(targetPath)) && !options.force) {
160
+ const actual = await readFile(targetPath, "utf8");
161
+ if (actual === content) {
162
+ console.log(`verified existing ${targetPath}`);
163
+ return { action: "verified", rendered: true, targetPath, targetRelative };
164
+ }
140
165
  console.log(`skipped existing ${targetPath}`);
141
166
  return { action: "skipped", rendered: false, targetPath, targetRelative };
142
167
  }
@@ -198,9 +223,10 @@ export async function installConsumerEntrypoints(resolvedConfig, options) {
198
223
  label: `Consumer entrypoint ${targetRelative}`,
199
224
  });
200
225
  await mkdir(path.dirname(targetPath), { recursive: true });
226
+ const existed = await exists(targetPath);
201
227
  await writeFile(targetPath, content);
202
228
  console.log(`wrote consumer entrypoint ${targetPath}`);
203
- records.push({ ...record, action: "wrote", rendered: true });
229
+ records.push({ ...record, action: existed ? "wrote" : "created", rendered: true });
204
230
  }
205
231
  }
206
232
 
@@ -255,9 +281,16 @@ async function writeGenerationManifest({
255
281
  rootPath: outputRoot,
256
282
  label: "Generation manifest .structor/manifest.json",
257
283
  });
284
+ const existed = await exists(manifestPath);
258
285
  await mkdir(path.dirname(manifestPath), { recursive: true });
259
286
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
260
287
  console.log(`wrote ${manifestPath}`);
288
+ return {
289
+ action: existed ? "wrote" : "created",
290
+ rendered: true,
291
+ targetPath: manifestPath,
292
+ targetRelative: path.relative(outputRoot, manifestPath).replaceAll(path.sep, "/"),
293
+ };
261
294
  }
262
295
 
263
296
  export async function generateHarness(config, {
@@ -268,6 +301,7 @@ export async function generateHarness(config, {
268
301
  dryRun = false,
269
302
  force = false,
270
303
  installConsumerEntrypoints: shouldInstallConsumerEntrypoints = false,
304
+ requireExistingConsumers = false,
271
305
  allowAbsoluteOutput = false,
272
306
  allowTemplateRepoConsumer = false,
273
307
  } = {}) {
@@ -279,11 +313,15 @@ export async function generateHarness(config, {
279
313
  configDir,
280
314
  outputPath,
281
315
  allowAbsoluteOutput,
282
- requireExistingConsumers: shouldInstallConsumerEntrypoints,
316
+ requireExistingConsumers: requireExistingConsumers || shouldInstallConsumerEntrypoints,
283
317
  allowTemplateRepoConsumer,
284
318
  });
285
319
  const { outputRoot, support } = resolvedConfig;
286
- const values = harnessTemplateValues(config, support, resolvedConfig.consumers, outputRoot);
320
+ const templateWorkspaceRoot = config.workspace?.root
321
+ ? path.resolve(configDir, config.workspace.root)
322
+ : path.resolve(configDir);
323
+ const templateOutputRoot = path.resolve(templateWorkspaceRoot, outputPath);
324
+ const values = harnessTemplateValues(config, support, resolvedConfig.consumers, templateOutputRoot, templateWorkspaceRoot);
287
325
  values.GENERATED_HARNESS_CONTRACT_MODULE = await readFile(
288
326
  path.join(repoRoot, "scripts/generated-harness-contract.mjs"),
289
327
  "utf8",
@@ -304,11 +342,36 @@ export async function generateHarness(config, {
304
342
  }
305
343
  }
306
344
 
345
+ const htmlViewsRoot = path.join(outputRoot, "ai", "views");
346
+ const htmlViewFilesBefore = renderedHtmlViewsScript
347
+ ? await collectExistingFiles(htmlViewsRoot)
348
+ : new Set();
307
349
  if (!dryRun && renderedHtmlViewsScript) {
308
- execFileSync(process.execPath, [path.join(outputRoot, "scripts/generate-html-views.mjs")], {
309
- cwd: outputRoot,
310
- stdio: "inherit",
311
- });
350
+ let htmlViewFilesAfter;
351
+ try {
352
+ execFileSync(process.execPath, [path.join(outputRoot, "scripts/generate-html-views.mjs")], {
353
+ cwd: outputRoot,
354
+ stdio: "inherit",
355
+ });
356
+ htmlViewFilesAfter = await collectExistingFiles(htmlViewsRoot);
357
+ } catch (error) {
358
+ htmlViewFilesAfter = await collectExistingFiles(htmlViewsRoot);
359
+ for (const targetPath of htmlViewFilesAfter) {
360
+ if (!htmlViewFilesBefore.has(targetPath)) {
361
+ await rm(targetPath, { force: true });
362
+ }
363
+ }
364
+ throw error;
365
+ }
366
+ for (const targetPath of htmlViewFilesAfter) {
367
+ if (htmlViewFilesBefore.has(targetPath)) continue;
368
+ generatedFiles.push({
369
+ action: "created",
370
+ rendered: true,
371
+ targetPath,
372
+ targetRelative: path.relative(outputRoot, targetPath).replaceAll(path.sep, "/"),
373
+ });
374
+ }
312
375
  } else if (!dryRun) {
313
376
  console.log("skipped HTML view generation because scripts/generate-html-views.mjs was not freshly rendered");
314
377
  }
@@ -317,8 +380,8 @@ export async function generateHarness(config, {
317
380
  ? await installConsumerEntrypoints(resolvedConfig, { dryRun, force, config: configPath })
318
381
  : [];
319
382
 
320
- if (!dryRun) {
321
- await writeGenerationManifest({
383
+ const manifestFile = !dryRun
384
+ ? await writeGenerationManifest({
322
385
  config,
323
386
  configContent: manifestConfigContent,
324
387
  configPath,
@@ -327,10 +390,15 @@ export async function generateHarness(config, {
327
390
  outputRoot,
328
391
  resolvedConfig,
329
392
  support,
330
- });
331
- }
393
+ })
394
+ : null;
332
395
 
333
- return resolvedConfig;
396
+ return {
397
+ resolvedConfig,
398
+ generatedFiles,
399
+ consumerEntrypoints,
400
+ manifestFile,
401
+ };
334
402
  }
335
403
 
336
404
  async function main() {
package/scripts/lib.mjs CHANGED
@@ -102,6 +102,15 @@ export function workspaceRootForConfig(configDir, templateRepoRoot = repoRoot) {
102
102
  : resolvedConfigDir;
103
103
  }
104
104
 
105
+ function resolveWorkspaceRoot(config, configDir, templateRepoRoot = repoRoot) {
106
+ const configuredRoot = config.workspace?.root;
107
+ if (typeof configuredRoot !== "string" || configuredRoot.trim() === "") {
108
+ return workspaceRootForConfig(configDir, templateRepoRoot);
109
+ }
110
+
111
+ return path.resolve(configDir, configuredRoot);
112
+ }
113
+
105
114
  export async function canonicalPathForWrite(targetPath) {
106
115
  let currentPath = path.resolve(targetPath);
107
116
  const missingSegments = [];
@@ -355,10 +364,15 @@ export async function resolveHarnessConfig(config, {
355
364
  }
356
365
 
357
366
  const resolvedConfigDir = path.resolve(configDir);
358
- const workspaceRoot = workspaceRootForConfig(resolvedConfigDir, templateRepoRoot);
359
- const requestedOutputRoot = path.resolve(resolvedConfigDir, outputPath);
367
+ const workspaceRoot = resolveWorkspaceRoot(config, resolvedConfigDir, templateRepoRoot);
368
+ const topologyRoot = config.workspace?.root ? workspaceRoot : resolvedConfigDir;
369
+ const requestedOutputRoot = path.resolve(topologyRoot, outputPath);
360
370
  const consumerRoots = [];
361
371
 
372
+ if (!isSameOrInsidePath(resolvedConfigDir, workspaceRoot)) {
373
+ errors.push(`${label}: config path ${resolvedConfigDir} must stay inside the workspace root ${workspaceRoot}.`);
374
+ }
375
+
362
376
  for (const consumer of config.consumers) {
363
377
  try {
364
378
  consumerRoots.push(assertSafeConsumerPath({
@@ -57,13 +57,12 @@ function consumerNames(consumers) {
57
57
  return consumers.map((consumer) => rawSlug(consumer.name, "consumer.name"));
58
58
  }
59
59
 
60
- function consumerConfig(resolvedConsumers, outputRoot) {
61
- const generatedWorkspaceRoot = path.dirname(outputRoot);
62
- return resolvedConsumers.map(({ config: consumer, root: consumerRoot }) => {
60
+ function consumerConfig(resolvedConsumers, workspaceRoot) {
61
+ return resolvedConsumers.map(({ config: consumer, requestedRoot, root: consumerRoot }) => {
63
62
  return {
64
63
  ...consumer,
65
64
  name: rawSlug(consumer.name, "consumer.name"),
66
- workspacePath: path.relative(generatedWorkspaceRoot, consumerRoot).replaceAll(path.sep, "/") || ".",
65
+ workspacePath: path.relative(workspaceRoot, requestedRoot ?? consumerRoot).replaceAll(path.sep, "/") || ".",
67
66
  };
68
67
  });
69
68
  }
@@ -72,16 +71,20 @@ export function renderedGeneratedScriptHashes(hashes) {
72
71
  return jsonLiteral(hashes);
73
72
  }
74
73
 
75
- export function harnessTemplateValues(config, support, resolvedConsumers, outputRoot) {
74
+ export function harnessTemplateValues(config, support, resolvedConsumers, outputRoot, workspaceRoot = path.dirname(outputRoot)) {
75
+ const workspaceHarnessPath = path.relative(workspaceRoot, outputRoot).replaceAll(path.sep, "/") || ".";
76
+
76
77
  return {
77
78
  PROJECT_NAME: markdownText(config.project.name),
78
79
  PROJECT_NAME_CODE: markdownCodeSpan(config.project.name),
79
80
  PROJECT_NAME_JSON: javascriptLiteral(config.project.name),
80
81
  PROJECT_SLUG: rawSlug(config.project.slug, "project.slug"),
81
82
  HARNESS_REPO_NAME: rawSlug(config.project.harnessRepoName, "project.harnessRepoName"),
83
+ WORKSPACE_HARNESS_PATH: workspaceHarnessPath.startsWith(".") ? workspaceHarnessPath : `./${workspaceHarnessPath}`,
84
+ WORKSPACE_ROOT_FROM_HARNESS_JSON: javascriptLiteral(path.relative(outputRoot, workspaceRoot).replaceAll(path.sep, "/") || "."),
82
85
  CONSUMER_REPOS_LIST: consumerList(config.consumers),
83
86
  CONSUMER_REPO_NAMES_JSON: javascriptLiteral(consumerNames(config.consumers)),
84
- CONSUMER_CONFIG_JSON: jsonLiteral(consumerConfig(resolvedConsumers, outputRoot)),
87
+ CONSUMER_CONFIG_JSON: jsonLiteral(consumerConfig(resolvedConsumers, workspaceRoot)),
85
88
  PRIMARY_CONSUMER_NAME: rawSlug(config.consumers[0].name, "consumer.name"),
86
89
  MODEL_OPENAI_ENABLED: javascriptBoolean(config.models.openai),
87
90
  MODEL_ANTHROPIC_ENABLED: javascriptBoolean(config.models.anthropic),
@@ -75,7 +75,7 @@ async function main() {
75
75
  console.log(`Workspace: ${workspaceRoot}`);
76
76
  console.log(`Preset: ${presetConfigPath}`);
77
77
 
78
- const resolvedConfig = await generateHarness(config, {
78
+ const { resolvedConfig } = await generateHarness(config, {
79
79
  configPath: presetConfigPath,
80
80
  configDir: workspaceRoot,
81
81
  dryRun: options.dryRun,
@@ -8,7 +8,7 @@ import { workspaceEntrypointsForSettings } from "./generated-harness-contract.mj
8
8
  import { assertSafeWriteTarget, exists } from "./lib/path-safety.mjs";
9
9
 
10
10
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
- const workspaceRoot = path.resolve(repoRoot, "..");
11
+ const workspaceRoot = path.resolve(repoRoot, {{WORKSPACE_ROOT_FROM_HARNESS_JSON}});
12
12
  const consumers = {{CONSUMER_CONFIG_JSON}};
13
13
  const models = {
14
14
  openai: {{MODEL_OPENAI_ENABLED}},
@@ -13,7 +13,7 @@ import {
13
13
  } from "./generated-harness-contract.mjs";
14
14
 
15
15
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16
- const workspaceRoot = path.resolve(repoRoot, "..");
16
+ const workspaceRoot = path.resolve(repoRoot, {{WORKSPACE_ROOT_FROM_HARNESS_JSON}});
17
17
  const harnessRepoName = "{{HARNESS_REPO_NAME}}";
18
18
  const consumers = {{CONSUMER_CONFIG_JSON}};
19
19
  const models = {
@@ -4,14 +4,13 @@ This workspace is governed by the {{PROJECT_NAME}} engineering harness.
4
4
 
5
5
  Read the harness first:
6
6
 
7
- 1. `./{{HARNESS_REPO_NAME}}/AGENTS.md`
8
- 2. `./{{HARNESS_REPO_NAME}}/ai/AGENTS.md`
9
- 3. `./{{HARNESS_REPO_NAME}}/ai/HUB.md`
10
- 4. `./{{HARNESS_REPO_NAME}}/ai/context.md`
7
+ 1. `{{WORKSPACE_HARNESS_PATH}}/AGENTS.md`
8
+ 2. `{{WORKSPACE_HARNESS_PATH}}/ai/AGENTS.md`
9
+ 3. `{{WORKSPACE_HARNESS_PATH}}/ai/HUB.md`
10
+ 4. `{{WORKSPACE_HARNESS_PATH}}/ai/context.md`
11
11
 
12
12
  Consumer repositories:
13
13
 
14
14
  {{CONSUMER_REPOS_LIST}}
15
15
 
16
16
  Keep this file short. Canonical policy belongs in the harness repo.
17
-
@@ -2,14 +2,14 @@
2
2
 
3
3
  This workspace is governed by the {{PROJECT_NAME}} engineering harness.
4
4
 
5
- @./{{HARNESS_REPO_NAME}}/ai/context.md
5
+ @{{WORKSPACE_HARNESS_PATH}}/ai/context.md
6
6
 
7
7
  Read the harness first:
8
8
 
9
- 1. `./{{HARNESS_REPO_NAME}}/CLAUDE.md`
10
- 2. `./{{HARNESS_REPO_NAME}}/ai/AGENTS.md`
11
- 3. `./{{HARNESS_REPO_NAME}}/ai/HUB.md`
12
- 4. `./{{HARNESS_REPO_NAME}}/ai/context.md`
9
+ 1. `{{WORKSPACE_HARNESS_PATH}}/CLAUDE.md`
10
+ 2. `{{WORKSPACE_HARNESS_PATH}}/ai/AGENTS.md`
11
+ 3. `{{WORKSPACE_HARNESS_PATH}}/ai/HUB.md`
12
+ 4. `{{WORKSPACE_HARNESS_PATH}}/ai/context.md`
13
13
 
14
14
  Consumer repositories:
15
15