nightralph 0.0.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 (101) hide show
  1. package/.claude/settings.local.json +5 -0
  2. package/AGENTS.md +60 -0
  3. package/CLAUDE.md +1 -0
  4. package/PROMPT.md +95 -0
  5. package/README.md +138 -0
  6. package/WIP.md +5 -0
  7. package/dist/docs-templates/domain.md +36 -0
  8. package/dist/docs-templates/issue-tracker-github.md +228 -0
  9. package/dist/docs-templates/issue-tracker.md +30 -0
  10. package/dist/docs-templates/triage-labels.md +15 -0
  11. package/dist/index.js +154 -0
  12. package/dist/index.js.map +7 -0
  13. package/dist/meta.json +207 -0
  14. package/dist/orchestrator.js +273 -0
  15. package/dist/orchestrator.js.map +7 -0
  16. package/dist/resolve.js +71 -0
  17. package/dist/resolve.js.map +7 -0
  18. package/dist/setup.js +151 -0
  19. package/dist/setup.js.map +7 -0
  20. package/dist/skills/domain-modeling/ADR-FORMAT.md +47 -0
  21. package/dist/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  22. package/dist/skills/domain-modeling/SKILL.md +74 -0
  23. package/dist/skills/domain-modeling/agents/openai.yaml +3 -0
  24. package/dist/skills/grill/SKILL.md +7 -0
  25. package/dist/skills/grill/agents/openai.yaml +5 -0
  26. package/dist/skills/grilling/SKILL.md +28 -0
  27. package/dist/skills/grilling/agents/openai.yaml +3 -0
  28. package/dist/skills/tdd/SKILL.md +38 -0
  29. package/dist/skills/tdd/agents/openai.yaml +3 -0
  30. package/dist/skills/tdd/mocking.md +59 -0
  31. package/dist/skills/tdd/tests.md +77 -0
  32. package/dist/skills/to-spec/SKILL.md +75 -0
  33. package/dist/skills/to-spec/agents/openai.yaml +5 -0
  34. package/dist/skills/to-tickets/SKILL.md +105 -0
  35. package/dist/skills/to-tickets/agents/openai.yaml +5 -0
  36. package/dist/skills/upstream.json +5 -0
  37. package/dist/src/index.d.ts +4 -0
  38. package/dist/src/index.d.ts.map +1 -0
  39. package/dist/src/orchestrator.d.ts +31 -0
  40. package/dist/src/orchestrator.d.ts.map +1 -0
  41. package/dist/src/resolve.d.ts +10 -0
  42. package/dist/src/resolve.d.ts.map +1 -0
  43. package/dist/src/setup.d.ts +13 -0
  44. package/dist/src/setup.d.ts.map +1 -0
  45. package/dist/test/integration.test.js +848 -0
  46. package/dist/test/integration.test.js.map +7 -0
  47. package/dist/test/orchestrator.test.js +749 -0
  48. package/dist/test/orchestrator.test.js.map +7 -0
  49. package/dist/test/setup.test.js +574 -0
  50. package/dist/test/setup.test.js.map +7 -0
  51. package/docs/README.md +12 -0
  52. package/docs/agents/domain.md +36 -0
  53. package/docs/agents/issue-tracker.md +30 -0
  54. package/docs/agents/triage-labels.md +15 -0
  55. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase-2-future-work.md +144 -0
  56. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_01.md +292 -0
  57. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_02.md +212 -0
  58. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/test-requirements.md +203 -0
  59. package/docs/skills.md +58 -0
  60. package/docs/test-plans/2026-08-27-issue-tracker-choice.md +95 -0
  61. package/eslint.config.js +142 -0
  62. package/lib.es5.d.ts +19 -0
  63. package/package.json +36 -0
  64. package/progress.log +190 -0
  65. package/ralph_claude.sh +263 -0
  66. package/scripts/clone-skills.sh +52 -0
  67. package/specs/prd.json +172 -0
  68. package/src/docs-templates/domain.md +36 -0
  69. package/src/docs-templates/issue-tracker-github.md +228 -0
  70. package/src/docs-templates/issue-tracker.md +30 -0
  71. package/src/docs-templates/triage-labels.md +15 -0
  72. package/src/index.ts +184 -0
  73. package/src/orchestrator.ts +324 -0
  74. package/src/resolve.ts +73 -0
  75. package/src/setup.ts +157 -0
  76. package/src/skills/domain-modeling/ADR-FORMAT.md +47 -0
  77. package/src/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  78. package/src/skills/domain-modeling/SKILL.md +74 -0
  79. package/src/skills/domain-modeling/agents/openai.yaml +3 -0
  80. package/src/skills/grill/SKILL.md +7 -0
  81. package/src/skills/grill/agents/openai.yaml +5 -0
  82. package/src/skills/grilling/SKILL.md +28 -0
  83. package/src/skills/grilling/agents/openai.yaml +3 -0
  84. package/src/skills/tdd/SKILL.md +38 -0
  85. package/src/skills/tdd/agents/openai.yaml +3 -0
  86. package/src/skills/tdd/mocking.md +59 -0
  87. package/src/skills/tdd/tests.md +77 -0
  88. package/src/skills/to-spec/SKILL.md +75 -0
  89. package/src/skills/to-spec/agents/openai.yaml +5 -0
  90. package/src/skills/to-tickets/SKILL.md +105 -0
  91. package/src/skills/to-tickets/agents/openai.yaml +5 -0
  92. package/src/skills/upstream.json +5 -0
  93. package/tasks/prd-integration-test-coverage.md +243 -0
  94. package/test/integration.test.ts +636 -0
  95. package/test/mock-agent-echo-args.js +4 -0
  96. package/test/mock-agent-fail.js +3 -0
  97. package/test/mock-agent-pass.js +3 -0
  98. package/test/orchestrator.test.ts +609 -0
  99. package/test/setup.test.ts +417 -0
  100. package/tsconfig.build.json +7 -0
  101. package/tsconfig.json +31 -0
@@ -0,0 +1,142 @@
1
+ import neostandard from 'newneostandard'
2
+ import tseslint from 'typescript-eslint'
3
+
4
+ // Local rule: forbid spaces around `|` and `&` in TypeScript union and
5
+ // intersection types, so `A|B` is required over `A | B`. Only flags
6
+ // operators whose operands share a line, leaving multiline unions (one
7
+ // member per line) untouched.
8
+ const localPlugin = {
9
+ rules: {
10
+ 'union-spacing': {
11
+ meta: {
12
+ type: 'layout',
13
+ fixable: 'whitespace',
14
+ schema: [],
15
+ messages: {
16
+ before: "Unexpected space before '{{op}}' in type.",
17
+ after: "Unexpected space after '{{op}}' in type."
18
+ }
19
+ },
20
+ create (context) {
21
+ const sc = context.sourceCode
22
+ const check = (node) => {
23
+ for (const member of node.types) {
24
+ const op = sc.getTokenBefore(member)
25
+ if (!op) continue
26
+ if (op.value !== '|' && op.value !== '&') continue
27
+ const prev = sc.getTokenBefore(op)
28
+ const next = sc.getTokenAfter(op)
29
+ // Only enforce when the operator sits inline
30
+ // between two members on a single line; leave
31
+ // multiline unions (leading/trailing pipe) be.
32
+ const inline = prev && next &&
33
+ prev.loc.end.line === op.loc.start.line &&
34
+ op.loc.end.line === next.loc.start.line
35
+ if (!inline) continue
36
+ if (prev.range[1] !== op.range[0]) {
37
+ context.report({
38
+ node: op,
39
+ messageId: 'before',
40
+ data: { op: op.value },
41
+ fix: (fixer) => fixer.removeRange(
42
+ [prev.range[1], op.range[0]]
43
+ )
44
+ })
45
+ }
46
+ if (op.range[1] !== next.range[0]) {
47
+ context.report({
48
+ node: op,
49
+ messageId: 'after',
50
+ data: { op: op.value },
51
+ fix: (fixer) => fixer.removeRange(
52
+ [op.range[1], next.range[0]]
53
+ )
54
+ })
55
+ }
56
+ }
57
+ }
58
+ return {
59
+ TSUnionType: check,
60
+ TSIntersectionType: check
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ export default tseslint.config(
68
+ {
69
+ ignores: [
70
+ 'lib.es5.d.ts',
71
+ 'dist/**',
72
+ 'public/**',
73
+ 'test/*.js'
74
+ ]
75
+ },
76
+
77
+ // JavaScript Standard Style, TypeScript-aware (flat-config successor
78
+ // to `eslint-config-standard`). Stylistic rules live under the
79
+ // `@stylistic/` namespace.
80
+ ...neostandard({ ts: true }),
81
+
82
+ // Standard Style overrides, applied to every file.
83
+ {
84
+ rules: {
85
+ '@stylistic/operator-linebreak': 'off',
86
+ '@stylistic/multiline-ternary': 'off',
87
+ '@stylistic/no-multiple-empty-lines': ['error', {
88
+ max: 1,
89
+ maxEOF: 1
90
+ }],
91
+ '@stylistic/indent': ['error', 4, {
92
+ SwitchCase: 1,
93
+ ignoredNodes: ['TemplateLiteral *']
94
+ }],
95
+ '@stylistic/comma-dangle': 'off',
96
+ '@stylistic/no-multi-spaces': ['error', {
97
+ ignoreEOLComments: true
98
+ }]
99
+ }
100
+ },
101
+
102
+ // `@typescript-eslint/recommended` plus our TypeScript overrides,
103
+ // scoped to TypeScript files where the parser and plugin are active.
104
+ {
105
+ files: ['**/*.ts', '**/*.tsx'],
106
+ extends: [tseslint.configs.recommended],
107
+ plugins: { local: localPlugin },
108
+ rules: {
109
+ '@typescript-eslint/no-explicit-any': 'off',
110
+ '@typescript-eslint/no-unused-vars': ['error', {
111
+ argsIgnorePattern: '^_',
112
+ varsIgnorePattern: '^_',
113
+ caughtErrorsIgnorePattern: '^_'
114
+ }],
115
+ '@typescript-eslint/consistent-type-imports': ['error', {
116
+ prefer: 'type-imports'
117
+ }],
118
+ // No space around the colon in type annotations
119
+ // (`const x:T`, params, returns). Keep `=>` in function
120
+ // types spaced via the arrow override.
121
+ '@stylistic/type-annotation-spacing': ['error', {
122
+ before: false,
123
+ after: false,
124
+ overrides: { arrow: 'ignore' }
125
+ }],
126
+ '@stylistic/arrow-spacing': ['error', {
127
+ before: true,
128
+ after: true
129
+ }],
130
+ // No space around the colon in object and type-literal
131
+ // members (`{ a:1 }`, `classes?:string[]`).
132
+ '@stylistic/key-spacing': ['error', {
133
+ beforeColon: false,
134
+ afterColon: false,
135
+ mode: 'strict'
136
+ }],
137
+ // Let `local/union-spacing` own union/intersection spacing.
138
+ '@stylistic/space-infix-ops': ['error', { ignoreTypes: true }],
139
+ 'local/union-spacing': 'error'
140
+ }
141
+ }
142
+ )
package/lib.es5.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** See https://stackoverflow.com/a/51390763/1470607 */
2
+ type Falsy = false | 0 | '' | null | undefined;
3
+
4
+ /**
5
+ * see https://www.karltarvas.com/typescript-array-filter-boolean.html
6
+ */
7
+ interface Array<T> {
8
+ /**
9
+ * Returns the elements of an array that meet the condition specified in a
10
+ * callback function.
11
+ * @param predicate A function that accepts up to three arguments. The filter
12
+ * method calls the predicate function one time for each element in the array.
13
+ * @param thisArg An object to which the this keyword can refer in the
14
+ * predicate function. If thisArg is omitted, undefined is used as the
15
+ * this value.
16
+ */
17
+ filter<S extends T>(predicate: BooleanConstructor, thisArg?: any)
18
+ : Exclude<S, Falsy>[];
19
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "nightralph",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "description": "Small orchestrator that runs a single-agent session per issue.",
6
+ "bin": {
7
+ "nightralph": "dist/index.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "scripts": {
13
+ "lint": "eslint \"./**/*.{ts,js}\"",
14
+ "start": "npm run build && node dist/index.js",
15
+ "build": "mkdir -p ./dist && rm -rf ./dist/* && npm run build-esm && npm run copy",
16
+ "build-esm": "esbuild src/index.ts src/orchestrator.ts src/setup.ts src/resolve.ts --format=esm --metafile=dist/meta.json --keep-names --tsconfig=tsconfig.build.json --outdir=./dist --sourcemap && tsc --emitDeclarationOnly --project tsconfig.build.json",
17
+ "copy": "mkdir -p ./dist/skills ./dist/docs-templates && cp -r ./src/skills/* ./dist/skills && cp -r ./src/docs-templates/* ./dist/docs-templates",
18
+ "clone-skills": "bash scripts/clone-skills.sh",
19
+ "toc": "markdown-toc --maxdepth 3 -i README.md",
20
+ "test": "esbuild test/setup.test.ts test/orchestrator.test.ts test/integration.test.ts --format=esm --bundle --packages=external --outdir=dist/test --sourcemap && node dist/test/setup.test.js && node dist/test/orchestrator.test.js && node dist/test/integration.test.js"
21
+ },
22
+ "devDependencies": {
23
+ "@substrate-system/tapzero": "^0.10.16",
24
+ "@types/node": "^26.3.0",
25
+ "esbuild": "^0.28.2",
26
+ "eslint": "^10.9.1",
27
+ "markdown-toc": "^1.2.0",
28
+ "newneostandard": "^0.14.3",
29
+ "typescript-eslint": "^8.68.0"
30
+ },
31
+ "author": "nichoth",
32
+ "dependencies": {
33
+ "@inquirer/prompts": "^8.7.0",
34
+ "yargs": "^18.1.0"
35
+ }
36
+ }
package/progress.log ADDED
@@ -0,0 +1,190 @@
1
+ ## 2026-08-27 - US-001: Unit tests for findFeatureDirs
2
+
3
+ Completed. Created test/integration.test.ts with 4 test cases (8 assertions):
4
+ - Returns empty array when .scratch/ does not exist
5
+ - Returns empty array when dirs lack issues/ subdirs
6
+ - Returns correct name, dir, spec for valid feature dirs
7
+ - Ignores non-directory entries in .scratch/
8
+
9
+ Refactoring note: extracted findFeatureDirs and resolveIssuesDir from
10
+ src/index.ts into src/resolve.ts. The top-level yargs(...).parse() call in
11
+ index.ts runs on import, which crashes test processes. Moving the pure
12
+ functions to resolve.ts lets tests import them without triggering CLI
13
+ parsing. index.ts re-exports both functions so the public API is unchanged.
14
+
15
+ Updated package.json:
16
+ - build-esm: added src/resolve.ts to esbuild entry points
17
+ - test: added integration.test.ts to build and run steps
18
+
19
+ All 128 tests pass (setup: 66, orchestrator: 54, integration: 8).
20
+ Lint clean.
21
+
22
+ ## 2026-08-27 - US-002: Unit tests for resolveIssuesDir with explicit flags
23
+
24
+ Completed. Added 2 test cases (4 assertions) to test/integration.test.ts:
25
+ - Returns { dir, spec } exactly as given when both flags are provided
26
+ - No filesystem scanning occurs (temp dir has no .scratch/)
27
+
28
+ Both tests confirm that resolveIssuesDir short-circuits when both --dir
29
+ and --spec are supplied, returning them directly without touching the
30
+ filesystem. The temp directory intentionally has no .scratch/ to prove
31
+ no scanning happens.
32
+
33
+ All 132 tests pass (setup: 66, orchestrator: 54, integration: 12).
34
+ Lint clean.
35
+
36
+ ## 2026-08-28 - US-003: Subprocess CLI smoke test for execute
37
+
38
+ Completed. Added 1 subprocess test (5 assertions) to
39
+ test/integration.test.ts:
40
+ - Spawns `node dist/index.js` with mock-agent-pass.js as provider
41
+ - Creates temp dir with 2 ticket files (second blocked by first)
42
+ and a spec.md
43
+ - Passes --dir, --spec, --timeout-secs 10 to the built CLI
44
+ - Asserts exit code 0
45
+ - Asserts both ticket files updated to **Status:** done on disk
46
+ - Asserts log files created for both tickets
47
+
48
+ Added helper functions: spawnCli (wraps execFile in a promise),
49
+ makeTicketBody (generates ticket markdown with status and blockers).
50
+ Also added readFileSync, existsSync, execFile imports to the test file.
51
+
52
+ All 137 tests pass (setup: 66, orchestrator: 54, integration: 17).
53
+ Lint clean.
54
+
55
+ ## 2026-08-28 - US-004: Create mock-agent-echo-args and test --model flag
56
+
57
+ Completed. Created test/mock-agent-echo-args.js and added 1 test case
58
+ (4 assertions) to test/integration.test.ts:
59
+ - mock-agent-echo-args.js writes JSON.stringify(process.argv) to stdout,
60
+ then reads stdin to completion and exits 0
61
+ - Test spawns CLI with -m test-model using the echo mock agent
62
+ - Asserts exit code 0
63
+ - Asserts log file created
64
+ - Asserts log contains --model flag and test-model value
65
+
66
+ Gotcha: new mock agent files need chmod +x to be spawnable by the
67
+ orchestrator (spawn uses the shebang, not node directly).
68
+
69
+ All 141 tests pass (setup: 66, orchestrator: 54, integration: 21).
70
+ Lint clean.
71
+
72
+ ## 2026-08-28 - US-005: Subprocess CLI test for --dry-run
73
+
74
+ Completed. Added 1 subprocess test (6 assertions) to
75
+ test/integration.test.ts:
76
+ - Spawns `node dist/index.js` with --dry-run, --dir, --spec flags
77
+ - Creates temp dir with 2 ticket files (second blocked by first)
78
+ and a spec.md
79
+ - Asserts exit code 0
80
+ - Asserts stdout contains Wave 1 with 01-alpha.md
81
+ - Asserts stdout contains Wave 2 with 02-beta.md
82
+ - Asserts stdout contains AGENT INSTRUCTIONS (prompt preview)
83
+ - Asserts both ticket files are NOT modified (still ready-for-agent)
84
+
85
+ All 147 tests pass (setup: 66, orchestrator: 54, integration: 27).
86
+ Lint clean.
87
+
88
+ ## 2026-08-28 - US-006: CLI exits gracefully when no tickets found
89
+
90
+ Completed. Added 1 subprocess test (2 assertions) to
91
+ test/integration.test.ts:
92
+ - Spawns `node dist/index.js` with --dir pointing at an empty
93
+ issues directory and a valid --spec
94
+ - Asserts exit code 0 (graceful, not a crash)
95
+ - Asserts stdout contains "No ticket files found"
96
+
97
+ The implementation already existed in src/index.ts (lines 148-150):
98
+ scanTickets returns an empty array for an empty directory, and
99
+ runExecute prints "No ticket files found. Exiting." then returns
100
+ normally (exit code 0). This test validates that behavior end-to-end
101
+ through the subprocess CLI.
102
+
103
+ All 149 tests pass (setup: 66, orchestrator: 54, integration: 29).
104
+ Lint clean.
105
+
106
+ ## 2026-08-28 - US-007: CLI exits with error for missing dir/spec
107
+
108
+ Completed. Added 2 subprocess tests (4 assertions) to
109
+ test/integration.test.ts:
110
+ - Test 1: passes --dir /nonexistent/path with a valid --spec file,
111
+ asserts non-zero exit code and stderr contains "not found"
112
+ - Test 2: passes valid --dir with --spec /nonexistent/spec.md,
113
+ asserts non-zero exit code and stderr contains "not found"
114
+
115
+ The validation logic already existed in src/index.ts (lines 136-143):
116
+ runExecute checks existsSync(dir) and existsSync(spec), prints
117
+ "Directory not found: ..." or "Spec file not found: ..." to stderr,
118
+ and calls process.exit(2). These tests validate that behavior
119
+ end-to-end through the subprocess CLI.
120
+
121
+ All 153 tests pass (setup: 66, orchestrator: 54, integration: 33).
122
+ Lint clean.
123
+
124
+ ## 2026-08-28 - US-008: Unknown blocker warning and stripping
125
+
126
+ Completed. Added 1 subprocess test (5 assertions) to
127
+ test/integration.test.ts:
128
+ - Creates a ticket with **Blocked by:** 99 where ticket 99 does not
129
+ exist in the issues directory
130
+ - Spawns the CLI as a subprocess with mock-agent-pass.js
131
+ - Asserts combined stdout+stderr contains "99" (the unknown blocker
132
+ number) and "not found" (the warning message)
133
+ - Asserts exit code 0 (ticket completed despite invalid blocker)
134
+ - Asserts ticket file updated to **Status:** done (blocker stripped)
135
+ - Asserts log file created
136
+
137
+ The warning and stripping logic already existed in src/index.ts
138
+ (lines 155-170): runExecute builds a Set of known ticket numbers,
139
+ iterates tickets to find blockers referencing unknown numbers,
140
+ console.warn's the warning, then filters the blockers array to only
141
+ known numbers. This test validates that behavior end-to-end through
142
+ the subprocess CLI.
143
+
144
+ All 158 tests pass (setup: 66, orchestrator: 54, integration: 38).
145
+ Lint clean.
146
+
147
+ ## 2026-08-28 - US-009: Dry-run deadlock detection
148
+
149
+ Completed. Added 1 subprocess test (4 assertions) to
150
+ test/integration.test.ts:
151
+ - Creates two tickets with circular dependencies (1 blocks 2,
152
+ 2 blocks 1)
153
+ - Spawns the CLI with --dry-run
154
+ - Asserts exit code 0
155
+ - Asserts stdout contains "Deadlocked tickets"
156
+ - Asserts stdout lists both ticket filenames (01-alpha.md,
157
+ 02-beta.md)
158
+
159
+ The deadlock detection logic already existed in dryRun()
160
+ (orchestrator.ts:222-231): after simulating waves, any tickets
161
+ still in ready-for-agent state are reported as deadlocked. This
162
+ test validates that behavior end-to-end through the subprocess
163
+ CLI with a circular dependency scenario.
164
+
165
+ All 162 tests pass (setup: 66, orchestrator: 54, integration: 42).
166
+ Lint clean.
167
+
168
+ ## 2026-08-28 - US-010: Setup-then-execute composition test
169
+
170
+ Completed. Added 1 composition test (12 assertions) to
171
+ test/integration.test.ts:
172
+ - Calls setup() with tracker:'local' in a temp project dir,
173
+ using dist/skills and dist/docs-templates as source dirs
174
+ - Creates .scratch/my-feature/issues/ with 2 ticket files
175
+ (second blocked by first) and a spec.md
176
+ - Spawns the CLI as a subprocess with mock-agent-pass.js,
177
+ passing --dir and --spec pointing at the .scratch layout
178
+ - Asserts exit code 0
179
+ - Asserts both tickets updated to **Status:** done
180
+ - Asserts log files created in .scratch/my-feature/logs/
181
+ - Asserts docs/agents/issue-tracker.md exists (from setup)
182
+ - Asserts all 6 .agents/skills/ directories exist (from setup)
183
+
184
+ Added import of setup and SKILL_DIRS from ../src/setup.js to
185
+ the integration test file.
186
+
187
+ All 174 tests pass (setup: 66, orchestrator: 54, integration: 54).
188
+ Lint clean.
189
+
190
+ All 10 user stories now have passes: true. PRD complete.
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # ralph_claude.sh -- one-task-per-session agentic loop for Claude Code.
4
+ #
5
+ # Each loop iteration = exactly ONE Claude session = ideally ONE task + commit.
6
+ # The bash loop owns the cadence; Claude owns the work inside a single session.
7
+ #
8
+ # Usage: ./ralph_claude.sh [MAX_ITERATIONS] [MAX_TURNS_PER_SESSION]
9
+ # ./ralph_claude.sh 200 80
10
+ #
11
+ set -uo pipefail # NOT -e: we handle per-iteration failures ourselves.
12
+
13
+ # --- Config -----------------------------------------------------------------
14
+ PROMPT_FILE="PROMPT.md"
15
+ PRD_FILE="specs/prd.json"
16
+ LOG_FILE="progress.log"
17
+ MODEL="claude-opus-4-6"
18
+ MAX_ITERATIONS=${1:-10}
19
+ MAX_TURNS_PER_SESSION=${2:-80} # hard ceiling so a single session can't run away
20
+ STALL_LIMIT=4 # bail after this many no-progress iterations
21
+ ITERATION=0
22
+ STALLED_COUNT=0
23
+ TMP_CONTEXT="" # globals so the EXIT trap can clean them up
24
+ TMP_CAPTURE=""
25
+
26
+ # Adaptive reasoning ladder, indexed by stall count (clamps at the last entry).
27
+ # Valid levels: low | medium | high | xhigh | max -- AVAILABILITY DEPENDS ON THE
28
+ # MODEL. If your $MODEL rejects a level (e.g. "max"), trim the high end here.
29
+ # With STALL_LIMIT=4 the loop runs at stall 0,1,2,3 before bailing, so all four
30
+ # tiers fire. Progress resets STALLED_COUNT=0, which drops effort back to the
31
+ # baseline automatically -- no separate "reset to medium" logic needed.
32
+ EFFORT_TIERS=("medium" "high" "max")
33
+
34
+ # --- UI Colors --------------------------------------------------------------
35
+ BLUE='\033[1;34m'; YELLOW='\033[1;33m'; GREEN='\033[1;32m'; MAGENTA='\033[1;35m'; RED='\033[1;31m'; NC='\033[0m'
36
+ log_info() { echo -e "${BLUE}â„šī¸ $1${NC}"; }
37
+ log_step() { echo -e "${YELLOW}➔ $1${NC}"; }
38
+ log_success() { echo -e "${GREEN}✅ $1${NC}"; }
39
+ log_warn() { echo -e "${MAGENTA}âš ī¸ $1${NC}"; }
40
+ log_error() { echo -e "${RED}✖ $1${NC}"; }
41
+
42
+ cleanup() { rm -f "${TMP_CONTEXT:-}" "${TMP_CAPTURE:-}" 2>/dev/null || true; }
43
+ trap cleanup EXIT
44
+ trap 'echo; log_warn "Stopping Ralph Loop..."; exit 130' INT
45
+
46
+ # --- Preflight --------------------------------------------------------------
47
+ for bin in claude jq git; do
48
+ command -v "$bin" >/dev/null 2>&1 || { log_error "'$bin' not found in PATH."; exit 1; }
49
+ done
50
+ for f in "$PROMPT_FILE" "$PRD_FILE"; do
51
+ [ -f "$f" ] || { log_error "Missing required file: $f"; exit 1; }
52
+ done
53
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { log_error "Not inside a git repo."; exit 1; }
54
+ touch "$LOG_FILE"
55
+
56
+ # --- Helpers ----------------------------------------------------------------
57
+ # Is the PRD parseable and shaped as expected? Distinguishes a real "done"
58
+ # state from a corrupted file (which must NOT look like success).
59
+ prd_ok() {
60
+ jq -e 'has("userStories") and (.userStories | type == "array")' "$PRD_FILE" >/dev/null 2>&1
61
+ }
62
+ # Count tasks not yet passing, excluding manual:true (those require a
63
+ # human-in-the-loop device session -- see ralph_device.sh -- and must never
64
+ # be selected or counted by this autonomous loop). Returns -1 on parse failure.
65
+ count_pending() {
66
+ jq -r '[.userStories[] | select((.passes == false or .passes == null) and (.manual != true))] | length' "$PRD_FILE" 2>/dev/null || echo "-1"
67
+ }
68
+ # Count manual:true stories still not passing, for the "only manual work
69
+ # left" report. These are surfaced but never selected or touched here.
70
+ count_pending_manual() {
71
+ jq -r '[.userStories[] | select((.passes == false or .passes == null) and (.manual == true))] | length' "$PRD_FILE" 2>/dev/null || echo "-1"
72
+ }
73
+ # Pretty list of pending tasks (for visibility / context). Manual stories are
74
+ # still listed, tagged, so a session knows they exist and to leave them alone.
75
+ pending_list() {
76
+ jq -r '.userStories[] | select(.passes == false or .passes == null) | "[\(.id)] \(.title)" + (if .manual == true then " (manual -- excluded from this loop)" else "" end)' "$PRD_FILE" 2>/dev/null
77
+ }
78
+ # Pretty list of manual:true stories still not passing.
79
+ pending_manual_list() {
80
+ jq -r '.userStories[] | select((.passes == false or .passes == null) and (.manual == true)) | "[\(.id)] \(.title)"' "$PRD_FILE" 2>/dev/null
81
+ }
82
+ # The single highest-priority pending, non-manual task. Empty string when the
83
+ # PRD is valid and has no such task left (caller checks count_pending_manual()
84
+ # to distinguish "fully done" from "only manual work remains").
85
+ current_task() {
86
+ jq -r '
87
+ [.userStories[] | select((.passes == false or .passes == null) and (.manual != true))]
88
+ | sort_by(.priority // 9999)
89
+ | (.[0] // empty) | "[\(.id)] \(.title)"
90
+ ' "$PRD_FILE" 2>/dev/null
91
+ }
92
+
93
+ # Map the stall count onto a reasoning effort level from EFFORT_TIERS, clamping
94
+ # at the last tier. This is the model-side half of escalation: it raises the
95
+ # actual thinking budget via `claude --effort`. The prompt-side half lives in
96
+ # reasoning_hint() below; both ramp on the same counter and stay in sync.
97
+ effort_for_stall() {
98
+ local n="$1" last=$(( ${#EFFORT_TIERS[@]} - 1 ))
99
+ [ "$n" -gt "$last" ] && n="$last"
100
+ echo "${EFFORT_TIERS[$n]}"
101
+ }
102
+
103
+ # Reasoning hint injected into the prompt, escalating with the stall count.
104
+ # With STALL_LIMIT=4 the hint is evaluated at stall 0,1,2,3 -> all tiers fire
105
+ # before the loop bails. (Prompt-based escalation is version-proof; it doesn't
106
+ # rely on any particular `claude` reasoning flag existing.) This reinforces the
107
+ # `--effort` ladder driven by effort_for_stall().
108
+ reasoning_hint() {
109
+ case "$1" in
110
+ 0) echo "" ;;
111
+ 1) echo "REASONING: Think step by step before acting." ;;
112
+ 2) echo "REASONING: This task stalled once already. Re-read progress.log and the spec, question your earlier assumptions, and try a different approach." ;;
113
+ *) echo "REASONING: This task has stalled repeatedly. Think very hard. Re-read the Codebase Patterns and recent learnings in progress.log, abandon the approaches that have already failed, and attempt a genuinely different strategy." ;;
114
+ esac
115
+ }
116
+
117
+ # --- Main loop --------------------------------------------------------------
118
+ while [ "$ITERATION" -lt "$MAX_ITERATIONS" ]; do
119
+ ITERATION=$((ITERATION + 1))
120
+
121
+ # 0. Never treat a broken PRD as completion.
122
+ if ! prd_ok; then
123
+ log_error "$PRD_FILE is missing/malformed or has no 'userStories' array. Stopping for human review."
124
+ exit 3
125
+ fi
126
+
127
+ # 1. Pick the one task for this session.
128
+ TASK="$(current_task)"
129
+ if [ -z "$TASK" ]; then
130
+ PENDING_MANUAL="$(count_pending_manual)"
131
+ if [ "$PENDING_MANUAL" -gt 0 ]; then
132
+ log_success "No headless work left — only manual:true stories remain:"
133
+ pending_manual_list
134
+ log_info "Run each via ./ralph_device.sh <story-id> in an attended session."
135
+ exit 0
136
+ fi
137
+ log_success "All tasks in $PRD_FILE pass. Nothing left to do."
138
+ exit 0
139
+ fi
140
+ PENDING_BEFORE="$(count_pending)"
141
+ HEAD_BEFORE="$(git rev-parse HEAD 2>/dev/null || echo none)"
142
+
143
+ # 1b. Pick this session's reasoning effort from the current stall count.
144
+ EFFORT="$(effort_for_stall "$STALLED_COUNT")"
145
+
146
+ # 2. Stall-driven friction relief: wipe stale local wrangler state.
147
+ if [ "$STALLED_COUNT" -eq 2 ] && [ -d ".wrangler/state/v3" ]; then
148
+ log_warn "Friction detected — clearing .wrangler/state/v3 ..."
149
+ rm -rf .wrangler/state/v3
150
+ fi
151
+
152
+ echo -e "${BLUE}------------------------------------------------------------${NC}"
153
+ log_info "ITERATION $ITERATION/$MAX_ITERATIONS | PENDING: $PENDING_BEFORE | STALL: $STALLED_COUNT/$STALL_LIMIT | EFFORT: $EFFORT"
154
+ log_step "TARGET: $TASK"
155
+ echo -e "${BLUE}------------------------------------------------------------${NC}"
156
+
157
+ # 3. Build the per-session context. Pin THIS task and forbid touching others.
158
+ TMP_CONTEXT="$(mktemp)"
159
+ {
160
+ cat "$PROMPT_FILE"
161
+ echo
162
+ echo "# THIS SESSION"
163
+ echo "Work on EXACTLY ONE task: $TASK"
164
+ echo "Complete it (write failing tests first, implement, lint, run only the"
165
+ echo "relevant tests, update progress.log, set passes:true for this story,"
166
+ echo "then commit). When that single task is done and committed, STOP. Do NOT"
167
+ echo "start any other task this session, and do NOT mark any other story passing."
168
+ echo
169
+ echo "Remaining tasks (context only — do not work on these now):"
170
+ pending_list
171
+ echo
172
+ echo "LAST_LOG_ENTRIES:"
173
+ tail -n 8 "$LOG_FILE" 2>/dev/null
174
+ HINT="$(reasoning_hint "$STALLED_COUNT")"
175
+ if [ -n "$HINT" ]; then echo; echo "$HINT"; fi
176
+ } > "$TMP_CONTEXT"
177
+
178
+ # 4. Execute one session. Stream events live for the human; tee the RAW jsonl
179
+ # to a capture file. tee sits BETWEEN claude and jq, so the capture holds
180
+ # claude's stdout regardless of any downstream jq hiccup.
181
+ # --effort raises the model-side reasoning budget as stalls accumulate.
182
+ TMP_CAPTURE="$(mktemp)"
183
+ set +o pipefail
184
+ cat "$TMP_CONTEXT" | claude -p --verbose \
185
+ --output-format stream-json \
186
+ --model "$MODEL" \
187
+ --effort "$EFFORT" \
188
+ --max-turns "$MAX_TURNS_PER_SESSION" \
189
+ --dangerously-skip-permissions \
190
+ | tee "$TMP_CAPTURE" \
191
+ | jq --unbuffered -r '
192
+ if .type == "system" then
193
+ ( .subtype as $s
194
+ | if $s == "init" then "đŸŸĻ session start (" + (.model // "?") + ")"
195
+ elif $s == "api_retry" then "âŗ retry " + ((.attempt // 0)|tostring) + "/" + ((.max_retries // 0)|tostring)
196
+ + " — " + (.error // "?") + " " + ((.error_status // "")|tostring)
197
+ elif $s == "compact_boundary" then "đŸ—œī¸ context compacted"
198
+ elif $s == "thinking_tokens" then "🧠 thinking: " + ([to_entries[] | select(.value|type=="number") | "\(.key)=\(.value)"] | join(" "))
199
+ else "đŸŸĻ system: " + ($s // "?") end )
200
+ elif .type == "assistant" then
201
+ (.message.content[]? |
202
+ if .type == "text" then "đŸ’Ŧ " + .text
203
+ elif .type == "tool_use" then "🔧 " + .name + ": " + ((.input // {}) | tostring | .[0:140])
204
+ else empty end)
205
+ elif .type == "user" then
206
+ (.message.content[]? | if .type == "tool_result" then "â†Šī¸ tool result" else empty end)
207
+ elif .type == "result" then
208
+ "✅ " + (.subtype // "done") + " ($" + ((.total_cost_usd // 0) | tostring) + ")"
209
+ else empty end
210
+ ' 2>/dev/null
211
+ # Capture pipe statuses IMMEDIATELY (before any other command clobbers them).
212
+ PSTATUS=("${PIPESTATUS[@]}")
213
+ set -o pipefail
214
+ CLAUDE_RC="${PSTATUS[1]:-0}" # 0=cat 1=claude 2=tee 3=jq
215
+ if [ "$CLAUDE_RC" -ne 0 ]; then
216
+ log_warn "claude exited non-zero (rc=$CLAUDE_RC) — auth/rate-limit/crash/unsupported --effort? Treating as no progress."
217
+ fi
218
+
219
+ # 5. Detect completion — but ONLY trust it if the PRD agrees all tasks pass.
220
+ # This kills false positives from claude merely *restating* the stop
221
+ # string while planning, and from any stream that echoes the prompt.
222
+ PENDING_AFTER="$(count_pending)"
223
+ if grep -q '<promise>COMPLETE</promise>' "$TMP_CAPTURE"; then
224
+ if [ "$PENDING_AFTER" -eq 0 ]; then
225
+ log_success "MISSION ACCOMPLISHED — COMPLETE reported and 0 tasks pending."
226
+ exit 0
227
+ else
228
+ log_warn "Saw COMPLETE signal but $PENDING_AFTER task(s) still pending — ignoring (likely planning text)."
229
+ fi
230
+ fi
231
+
232
+ # 6. Progress = a NEW COMMIT or FEWER PENDING TASKS. A merely-dirty tree is
233
+ # NOT progress: uncommitted flailing must not reset the stall counter,
234
+ # otherwise reasoning never escalates.
235
+ HEAD_AFTER="$(git rev-parse HEAD 2>/dev/null || echo none)"
236
+ if [ -n "$(git status --porcelain)" ]; then
237
+ log_warn "Working tree left dirty (uncommitted changes). Not counted as progress — Claude should commit completed work."
238
+ fi
239
+
240
+ if [ "$HEAD_AFTER" != "$HEAD_BEFORE" ] || [ "$PENDING_AFTER" -lt "$PENDING_BEFORE" ]; then
241
+ DELTA=$((PENDING_BEFORE - PENDING_AFTER))
242
+ if [ "$DELTA" -gt 1 ]; then
243
+ log_warn "Pending dropped by $DELTA (>1) — session completed more than one task. Tighten the prompt if this recurs."
244
+ fi
245
+ log_success "Progress (commits: $HEAD_BEFORE → $HEAD_AFTER, pending: $PENDING_BEFORE → $PENDING_AFTER). Effort resets to ${EFFORT_TIERS[0]}."
246
+ STALLED_COUNT=0
247
+ else
248
+ STALLED_COUNT=$((STALLED_COUNT + 1))
249
+ log_warn "No progress this session (stall $STALLED_COUNT/$STALL_LIMIT). Next effort: $(effort_for_stall "$STALLED_COUNT")."
250
+ fi
251
+
252
+ # Clean this iteration's tempfiles now (the EXIT trap is the safety net).
253
+ rm -f "$TMP_CONTEXT" "$TMP_CAPTURE"; TMP_CONTEXT=""; TMP_CAPTURE=""
254
+
255
+ # 7. Bail if we're truly stuck — avoid burning all iterations on a no-op.
256
+ if [ "$STALLED_COUNT" -ge "$STALL_LIMIT" ]; then
257
+ log_error "Stalled $STALL_LIMIT sessions in a row with escalation exhausted. Stopping for human review."
258
+ exit 2
259
+ fi
260
+ done
261
+
262
+ log_warn "Reached MAX_ITERATIONS=$MAX_ITERATIONS with tasks still pending ($(count_pending) left)."
263
+ exit 0