feature-loop-harness-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +53 -0
  2. package/bin/flh.js +391 -0
  3. package/package.json +29 -0
  4. package/templates/default/.codex/config.toml +2 -0
  5. package/templates/default/.codex/hooks/user-prompt-submit.sh +5 -0
  6. package/templates/default/.codex/hooks.json +16 -0
  7. package/templates/default/.flh/docs/FEATURE_IMPLEMENTATION_PIPELINE.md +454 -0
  8. package/templates/default/.flh/docs/PROJECT_WORKFLOW.md +270 -0
  9. package/templates/default/.flh/docs/REVIEW_PATCH_PIPELINE.md +166 -0
  10. package/templates/default/.flh/hooks/user_prompt_submit.py +1440 -0
  11. package/templates/default/.flh/runtime/STATE.md +84 -0
  12. package/templates/default/.flh/scripts/pre_commit.py +674 -0
  13. package/templates/default/.flh/workflow/docs-spec.yml +134 -0
  14. package/templates/default/.flh/workflow/flow.yml +82 -0
  15. package/templates/default/.flh/workflow/request-patterns.yml +265 -0
  16. package/templates/default/.flh/workflow/state-actions.yml +117 -0
  17. package/templates/default/.flh/workflow/transition-guards.yml +57 -0
  18. package/templates/default/.husky/pre-commit +3 -0
  19. package/templates/default/AGENTS.md +44 -0
  20. package/templates/default/HARNESS_MANUAL.md +1105 -0
  21. package/templates/default/README.md +251 -0
  22. package/templates/default/docs/API.md +41 -0
  23. package/templates/default/docs/ARCHITECTURE.md +86 -0
  24. package/templates/default/docs/DB_SCHEMA.md +149 -0
  25. package/templates/default/docs/DESIGN.md +52 -0
  26. package/templates/default/docs/MVP.md +47 -0
  27. package/templates/default/docs/QUALITY_SCORE.md +54 -0
  28. package/templates/default/docs/docs-map.md +64 -0
  29. package/templates/default/docs/features/active/.gitkeep +1 -0
  30. package/templates/default/docs/features/backlog/.gitkeep +1 -0
  31. package/templates/default/docs/features/blocked/.gitkeep +1 -0
  32. package/templates/default/docs/features/done/.gitkeep +1 -0
  33. package/templates/default/docs/features/feature-index.md +21 -0
  34. package/templates/default/docs/features/postponed/.gitkeep +1 -0
  35. package/templates/default/docs/features/ready/.gitkeep +1 -0
  36. package/templates/default/docs/features/review/.gitkeep +1 -0
  37. package/templates/default/docs/source-layout.yml +33 -0
  38. package/templates/default/gitignore.template +9 -0
  39. package/templates/default/tests/hooks/test_pre_commit.py +659 -0
  40. package/templates/default/tests/hooks/test_user_prompt_submit.py +750 -0
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Feature Loop Harness CLI
2
+ ![image](https://res.cloudinary.com/dddvvp9de/image/upload/v1780124834/FeatureLoopHarness_zndch9.png)
3
+
4
+ npx installer CLI for Feature Loop Harness, a Codex workflow guardrail template.
5
+
6
+ For more details, or if you prefer the clone-based installation flow, see [jkuminga/FeatureLoopHarness](https://github.com/jkuminga/FeatureLoopHarness).
7
+
8
+ ## Usage
9
+
10
+ Run the installer from the root of a new or early-stage project:
11
+
12
+ ```sh
13
+ npx feature-loop-harness-cli init
14
+ ```
15
+
16
+ Or target a specific directory:
17
+
18
+ ```sh
19
+ npx feature-loop-harness-cli init --target ./my-project
20
+ ```
21
+
22
+ The installer copies the harness template, merges npm scripts and dev dependencies into `package.json`, initializes Git when needed, runs `npm install`, installs Husky metadata, and runs a lightweight verification pass.
23
+
24
+ ## Options
25
+
26
+ ```text
27
+ init [directory]
28
+ --target <path> Install into a target directory.
29
+ --force Overwrite existing harness files when conflicts are found.
30
+ --skip-install Do not run npm install or Husky setup.
31
+ --skip-verify Do not run the verification commands.
32
+ --skip-git Do not run git init when the target has no .git directory.
33
+ --dry-run Print planned actions without writing files.
34
+ -h, --help Show help.
35
+ ```
36
+
37
+ ## After Install
38
+
39
+ Codex project-local hook approval still requires an explicit user action. From the target project root:
40
+
41
+ ```sh
42
+ codex
43
+ /hooks
44
+ ```
45
+
46
+ Approve the `UserPromptSubmit` hook after checking that it points to `.codex/hooks/user-prompt-submit.sh`.
47
+
48
+ ## Development
49
+
50
+ ```sh
51
+ npm test
52
+ npm pack
53
+ ```
package/bin/flh.js ADDED
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { spawnSync } = require("child_process");
7
+
8
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
9
+ const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, "templates", "default");
10
+ const OPTIONAL_SKIP_IF_EXISTS = new Set(["README.md"]);
11
+
12
+ function printHelp() {
13
+ console.log(`Feature Loop Harness CLI
14
+
15
+ Usage:
16
+ flh init [directory] [options]
17
+ feature-loop-harness-cli init [directory] [options]
18
+
19
+ Options:
20
+ --target <path> Install into a target directory.
21
+ --force Overwrite existing harness files when conflicts are found.
22
+ --skip-install Do not run npm install or Husky setup.
23
+ --skip-verify Do not run verification commands.
24
+ --skip-git Do not run git init when the target has no .git directory.
25
+ --dry-run Print planned actions without writing files.
26
+ -h, --help Show help.
27
+ `);
28
+ }
29
+
30
+ function fail(message) {
31
+ console.error(`flh: ${message}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ function parseArgs(argv) {
36
+ const args = [...argv];
37
+ if (args.includes("-h") || args.includes("--help")) {
38
+ return { help: true };
39
+ }
40
+
41
+ const command = args[0] && !args[0].startsWith("-") ? args.shift() : "init";
42
+ const options = {
43
+ target: null,
44
+ force: false,
45
+ skipInstall: false,
46
+ skipVerify: false,
47
+ skipGit: false,
48
+ dryRun: false,
49
+ };
50
+ let positionalTarget = null;
51
+
52
+ for (let index = 0; index < args.length; index += 1) {
53
+ const arg = args[index];
54
+
55
+ if (arg === "--target") {
56
+ options.target = args[index + 1];
57
+ index += 1;
58
+ } else if (arg.startsWith("--target=")) {
59
+ options.target = arg.slice("--target=".length);
60
+ } else if (arg === "--force") {
61
+ options.force = true;
62
+ } else if (arg === "--skip-install") {
63
+ options.skipInstall = true;
64
+ } else if (arg === "--skip-verify") {
65
+ options.skipVerify = true;
66
+ } else if (arg === "--skip-git") {
67
+ options.skipGit = true;
68
+ } else if (arg === "--dry-run") {
69
+ options.dryRun = true;
70
+ } else if (!arg.startsWith("-") && positionalTarget === null) {
71
+ positionalTarget = arg;
72
+ } else {
73
+ fail(`unknown option: ${arg}`);
74
+ }
75
+ }
76
+
77
+ options.target = options.target || positionalTarget || ".";
78
+ return { command, options };
79
+ }
80
+
81
+ function listFiles(root) {
82
+ const result = [];
83
+
84
+ function walk(current) {
85
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
86
+ const fullPath = path.join(current, entry.name);
87
+ if (entry.isDirectory()) {
88
+ walk(fullPath);
89
+ } else if (entry.isFile()) {
90
+ result.push(fullPath);
91
+ }
92
+ }
93
+ }
94
+
95
+ walk(root);
96
+ return result;
97
+ }
98
+
99
+ function relativeTemplatePath(filePath) {
100
+ return path.relative(TEMPLATE_ROOT, filePath).split(path.sep).join("/");
101
+ }
102
+
103
+ function readJson(filePath) {
104
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
105
+ }
106
+
107
+ function writeJson(filePath, value, dryRun) {
108
+ if (dryRun) {
109
+ console.log(`[dry-run] write ${filePath}`);
110
+ return;
111
+ }
112
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
113
+ }
114
+
115
+ function ensureDirectory(dir, dryRun) {
116
+ if (dryRun) {
117
+ console.log(`[dry-run] mkdir -p ${dir}`);
118
+ return;
119
+ }
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ }
122
+
123
+ function copyFile(src, dest, dryRun) {
124
+ if (dryRun) {
125
+ console.log(`[dry-run] copy ${relativeTemplatePath(src)} -> ${dest}`);
126
+ return;
127
+ }
128
+
129
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
130
+ fs.copyFileSync(src, dest);
131
+ fs.chmodSync(dest, fs.statSync(src).mode & 0o777);
132
+ }
133
+
134
+ function mergeGitignore(templatePath, targetPath, dryRun) {
135
+ const templateLines = fs
136
+ .readFileSync(templatePath, "utf8")
137
+ .split(/\r?\n/)
138
+ .filter(Boolean);
139
+
140
+ if (!fs.existsSync(targetPath)) {
141
+ copyFile(templatePath, targetPath, dryRun);
142
+ return;
143
+ }
144
+
145
+ const current = fs.readFileSync(targetPath, "utf8");
146
+ const currentLines = new Set(current.split(/\r?\n/).map((line) => line.trim()));
147
+ const missing = templateLines.filter((line) => !currentLines.has(line.trim()));
148
+
149
+ if (missing.length === 0) {
150
+ console.log("gitignore: already contains harness ignore rules");
151
+ return;
152
+ }
153
+
154
+ if (dryRun) {
155
+ console.log(`[dry-run] append ${missing.length} entries to ${targetPath}`);
156
+ return;
157
+ }
158
+
159
+ const separator = current.endsWith("\n") ? "" : "\n";
160
+ fs.appendFileSync(targetPath, `${separator}\n# Feature Loop Harness\n${missing.join("\n")}\n`, "utf8");
161
+ console.log(`gitignore: appended ${missing.length} harness ignore rules`);
162
+ }
163
+
164
+ function copyTemplate(targetDir, options) {
165
+ const files = listFiles(TEMPLATE_ROOT);
166
+ const conflicts = [];
167
+
168
+ for (const src of files) {
169
+ const rel = relativeTemplatePath(src);
170
+ const dest = path.join(targetDir, rel);
171
+
172
+ if (rel === "gitignore.template" || OPTIONAL_SKIP_IF_EXISTS.has(rel)) {
173
+ continue;
174
+ }
175
+
176
+ if (fs.existsSync(dest) && !options.force) {
177
+ conflicts.push(rel);
178
+ }
179
+ }
180
+
181
+ if (conflicts.length > 0) {
182
+ fail(
183
+ [
184
+ "target already contains harness files:",
185
+ ...conflicts.map((file) => ` - ${file}`),
186
+ "rerun with --force only if overwriting is intended.",
187
+ ].join("\n")
188
+ );
189
+ }
190
+
191
+ for (const src of files) {
192
+ const rel = relativeTemplatePath(src);
193
+ const dest = path.join(targetDir, rel);
194
+
195
+ if (rel === "gitignore.template") {
196
+ mergeGitignore(src, path.join(targetDir, ".gitignore"), options.dryRun);
197
+ continue;
198
+ }
199
+
200
+ if (OPTIONAL_SKIP_IF_EXISTS.has(rel) && fs.existsSync(dest) && !options.force) {
201
+ console.log(`${rel}: kept existing file`);
202
+ continue;
203
+ }
204
+
205
+ copyFile(src, dest, options.dryRun);
206
+ }
207
+ }
208
+
209
+ function packageNameFromDirectory(targetDir) {
210
+ const fallback = "feature-loop-project";
211
+ const name = path.basename(targetDir).toLowerCase();
212
+ const normalized = name
213
+ .replace(/[^a-z0-9._-]+/g, "-")
214
+ .replace(/^-+|-+$/g, "");
215
+ return normalized || fallback;
216
+ }
217
+
218
+ function mergePackageJson(targetDir, dryRun) {
219
+ const packagePath = path.join(targetDir, "package.json");
220
+ const exists = fs.existsSync(packagePath);
221
+ const pkg = exists
222
+ ? readJson(packagePath)
223
+ : {
224
+ name: packageNameFromDirectory(targetDir),
225
+ version: "0.1.0",
226
+ private: true,
227
+ };
228
+
229
+ pkg.scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
230
+ pkg.devDependencies =
231
+ pkg.devDependencies && typeof pkg.devDependencies === "object" ? pkg.devDependencies : {};
232
+
233
+ if (!pkg.scripts["flh:test"]) {
234
+ pkg.scripts["flh:test"] =
235
+ "python3 -m unittest tests/hooks/test_user_prompt_submit.py tests/hooks/test_pre_commit.py";
236
+ }
237
+
238
+ if (!pkg.scripts["flh:precommit"]) {
239
+ pkg.scripts["flh:precommit"] = "python3 .flh/scripts/pre_commit.py";
240
+ }
241
+
242
+ if (!pkg.scripts.test) {
243
+ pkg.scripts.test = "npm run flh:test";
244
+ }
245
+
246
+ if (!pkg.scripts.prepare) {
247
+ pkg.scripts.prepare = "husky";
248
+ } else if (!String(pkg.scripts.prepare).includes("husky")) {
249
+ console.log("package.json: kept existing prepare script; run `npx husky` manually if needed");
250
+ }
251
+
252
+ const allDependencies = {
253
+ ...(pkg.dependencies || {}),
254
+ ...(pkg.devDependencies || {}),
255
+ };
256
+
257
+ if (!allDependencies.husky) {
258
+ pkg.devDependencies.husky = "^9.1.7";
259
+ }
260
+
261
+ if (!allDependencies["lint-staged"]) {
262
+ pkg.devDependencies["lint-staged"] = "^17.0.6";
263
+ }
264
+
265
+ if (!pkg["lint-staged"] || typeof pkg["lint-staged"] !== "object") {
266
+ pkg["lint-staged"] = {};
267
+ }
268
+
269
+ if (!pkg["lint-staged"]["*.{js,jsx,ts,tsx}"]) {
270
+ pkg["lint-staged"]["*.{js,jsx,ts,tsx}"] = ["eslint --fix", "prettier --write"];
271
+ }
272
+
273
+ writeJson(packagePath, pkg, dryRun);
274
+ console.log(`${exists ? "updated" : "created"} package.json`);
275
+ }
276
+
277
+ function runCommand(command, args, cwd, options) {
278
+ const printable = [command, ...args].join(" ");
279
+ if (options.dryRun) {
280
+ console.log(`[dry-run] ${printable}`);
281
+ return;
282
+ }
283
+
284
+ console.log(`running: ${printable}`);
285
+ const result = spawnSync(command, args, {
286
+ cwd,
287
+ stdio: "inherit",
288
+ shell: false,
289
+ });
290
+
291
+ if (result.status !== 0) {
292
+ fail(`${printable} failed with exit code ${result.status}`);
293
+ }
294
+ }
295
+
296
+ function runHookSmoke(targetDir, dryRun) {
297
+ const hookPath = path.join(targetDir, ".codex", "hooks", "user-prompt-submit.sh");
298
+ if (dryRun) {
299
+ console.log(`[dry-run] ${hookPath} < /q hook smoke test`);
300
+ return;
301
+ }
302
+
303
+ const result = spawnSync(hookPath, {
304
+ cwd: targetDir,
305
+ input: "/q hook smoke test\n",
306
+ text: true,
307
+ encoding: "utf8",
308
+ stdout: "pipe",
309
+ stderr: "pipe",
310
+ });
311
+
312
+ if (result.status !== 0) {
313
+ process.stdout.write(result.stdout || "");
314
+ process.stderr.write(result.stderr || "");
315
+ fail(`hook smoke test failed with exit code ${result.status}`);
316
+ }
317
+
318
+ console.log("hook smoke test passed");
319
+ }
320
+
321
+ function initGit(targetDir, options) {
322
+ if (options.skipGit) {
323
+ return;
324
+ }
325
+
326
+ if (fs.existsSync(path.join(targetDir, ".git"))) {
327
+ console.log("git: existing repository detected");
328
+ return;
329
+ }
330
+
331
+ runCommand("git", ["init"], targetDir, options);
332
+ }
333
+
334
+ function installDependencies(targetDir, options) {
335
+ if (options.skipInstall) {
336
+ console.log("npm install: skipped");
337
+ return;
338
+ }
339
+
340
+ runCommand("npm", ["install"], targetDir, options);
341
+ runCommand("npx", ["husky"], targetDir, options);
342
+ }
343
+
344
+ function verifyInstall(targetDir, options) {
345
+ if (options.skipVerify) {
346
+ console.log("verification: skipped");
347
+ return;
348
+ }
349
+
350
+ runCommand("npm", ["run", "flh:test"], targetDir, options);
351
+ runHookSmoke(targetDir, options.dryRun);
352
+ }
353
+
354
+ function printNextSteps(targetDir) {
355
+ console.log(`
356
+ Feature Loop Harness installed in:
357
+ ${targetDir}
358
+
359
+ Next steps:
360
+ 1. Run: codex
361
+ 2. Inside Codex, run: /hooks
362
+ 3. Approve the UserPromptSubmit hook after checking .codex/hooks/user-prompt-submit.sh.
363
+ `);
364
+ }
365
+
366
+ function main() {
367
+ const parsed = parseArgs(process.argv.slice(2));
368
+ if (parsed.help) {
369
+ printHelp();
370
+ return;
371
+ }
372
+
373
+ if (parsed.command !== "init") {
374
+ fail(`unknown command: ${parsed.command}`);
375
+ }
376
+
377
+ if (!fs.existsSync(TEMPLATE_ROOT)) {
378
+ fail(`template directory is missing: ${TEMPLATE_ROOT}`);
379
+ }
380
+
381
+ const targetDir = path.resolve(process.cwd(), parsed.options.target);
382
+ ensureDirectory(targetDir, parsed.options.dryRun);
383
+ copyTemplate(targetDir, parsed.options);
384
+ mergePackageJson(targetDir, parsed.options.dryRun);
385
+ initGit(targetDir, parsed.options);
386
+ installDependencies(targetDir, parsed.options);
387
+ verifyInstall(targetDir, parsed.options);
388
+ printNextSteps(targetDir);
389
+ }
390
+
391
+ main();
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "feature-loop-harness-cli",
3
+ "version": "0.1.0",
4
+ "description": "npx installer CLI for Feature Loop Harness, a Codex workflow guardrail template.",
5
+ "license": "ISC",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "flh": "bin/flh.js",
9
+ "feature-loop-harness-cli": "bin/flh.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "templates",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node bin/flh.js --help && npm pack --dry-run"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "codex",
24
+ "workflow",
25
+ "harness",
26
+ "npx",
27
+ "cli"
28
+ ]
29
+ }
@@ -0,0 +1,2 @@
1
+ [features]
2
+ hooks = true
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
5
+ exec python3 "$ROOT/.flh/hooks/user_prompt_submit.py"
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": ".codex/hooks/user-prompt-submit.sh",
9
+ "statusMessage": "Checking harness workflow state",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }