@scanton/phase2s 0.13.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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/bin/phase2s.d.ts +3 -0
  4. package/dist/bin/phase2s.d.ts.map +1 -0
  5. package/dist/bin/phase2s.js +7 -0
  6. package/dist/bin/phase2s.js.map +1 -0
  7. package/dist/src/cli/index.d.ts +2 -0
  8. package/dist/src/cli/index.d.ts.map +1 -0
  9. package/dist/src/cli/index.js +499 -0
  10. package/dist/src/cli/index.js.map +1 -0
  11. package/dist/src/core/agent.d.ts +63 -0
  12. package/dist/src/core/agent.d.ts.map +1 -0
  13. package/dist/src/core/agent.js +178 -0
  14. package/dist/src/core/agent.js.map +1 -0
  15. package/dist/src/core/config.d.ts +52 -0
  16. package/dist/src/core/config.d.ts.map +1 -0
  17. package/dist/src/core/config.js +103 -0
  18. package/dist/src/core/config.js.map +1 -0
  19. package/dist/src/core/conversation.d.ts +44 -0
  20. package/dist/src/core/conversation.d.ts.map +1 -0
  21. package/dist/src/core/conversation.js +128 -0
  22. package/dist/src/core/conversation.js.map +1 -0
  23. package/dist/src/core/memory.d.ts +24 -0
  24. package/dist/src/core/memory.d.ts.map +1 -0
  25. package/dist/src/core/memory.js +65 -0
  26. package/dist/src/core/memory.js.map +1 -0
  27. package/dist/src/mcp/server.d.ts +73 -0
  28. package/dist/src/mcp/server.d.ts.map +1 -0
  29. package/dist/src/mcp/server.js +215 -0
  30. package/dist/src/mcp/server.js.map +1 -0
  31. package/dist/src/providers/codex.d.ts +28 -0
  32. package/dist/src/providers/codex.d.ts.map +1 -0
  33. package/dist/src/providers/codex.js +162 -0
  34. package/dist/src/providers/codex.js.map +1 -0
  35. package/dist/src/providers/index.d.ts +6 -0
  36. package/dist/src/providers/index.d.ts.map +1 -0
  37. package/dist/src/providers/index.js +13 -0
  38. package/dist/src/providers/index.js.map +1 -0
  39. package/dist/src/providers/openai.d.ts +36 -0
  40. package/dist/src/providers/openai.d.ts.map +1 -0
  41. package/dist/src/providers/openai.js +117 -0
  42. package/dist/src/providers/openai.js.map +1 -0
  43. package/dist/src/providers/types.d.ts +34 -0
  44. package/dist/src/providers/types.d.ts.map +1 -0
  45. package/dist/src/providers/types.js +2 -0
  46. package/dist/src/providers/types.js.map +1 -0
  47. package/dist/src/skills/index.d.ts +3 -0
  48. package/dist/src/skills/index.d.ts.map +1 -0
  49. package/dist/src/skills/index.js +2 -0
  50. package/dist/src/skills/index.js.map +1 -0
  51. package/dist/src/skills/loader.d.ts +23 -0
  52. package/dist/src/skills/loader.d.ts.map +1 -0
  53. package/dist/src/skills/loader.js +136 -0
  54. package/dist/src/skills/loader.js.map +1 -0
  55. package/dist/src/skills/types.d.ts +14 -0
  56. package/dist/src/skills/types.d.ts.map +1 -0
  57. package/dist/src/skills/types.js +2 -0
  58. package/dist/src/skills/types.js.map +1 -0
  59. package/dist/src/tools/file-read.d.ts +3 -0
  60. package/dist/src/tools/file-read.d.ts.map +1 -0
  61. package/dist/src/tools/file-read.js +50 -0
  62. package/dist/src/tools/file-read.js.map +1 -0
  63. package/dist/src/tools/file-write.d.ts +3 -0
  64. package/dist/src/tools/file-write.d.ts.map +1 -0
  65. package/dist/src/tools/file-write.js +90 -0
  66. package/dist/src/tools/file-write.js.map +1 -0
  67. package/dist/src/tools/glob-tool.d.ts +3 -0
  68. package/dist/src/tools/glob-tool.d.ts.map +1 -0
  69. package/dist/src/tools/glob-tool.js +38 -0
  70. package/dist/src/tools/glob-tool.js.map +1 -0
  71. package/dist/src/tools/grep-tool.d.ts +3 -0
  72. package/dist/src/tools/grep-tool.d.ts.map +1 -0
  73. package/dist/src/tools/grep-tool.js +59 -0
  74. package/dist/src/tools/grep-tool.js.map +1 -0
  75. package/dist/src/tools/index.d.ts +5 -0
  76. package/dist/src/tools/index.d.ts.map +1 -0
  77. package/dist/src/tools/index.js +17 -0
  78. package/dist/src/tools/index.js.map +1 -0
  79. package/dist/src/tools/registry.d.ts +13 -0
  80. package/dist/src/tools/registry.d.ts.map +1 -0
  81. package/dist/src/tools/registry.js +50 -0
  82. package/dist/src/tools/registry.js.map +1 -0
  83. package/dist/src/tools/sandbox.d.ts +29 -0
  84. package/dist/src/tools/sandbox.d.ts.map +1 -0
  85. package/dist/src/tools/sandbox.js +84 -0
  86. package/dist/src/tools/sandbox.js.map +1 -0
  87. package/dist/src/tools/shell.d.ts +10 -0
  88. package/dist/src/tools/shell.d.ts.map +1 -0
  89. package/dist/src/tools/shell.js +108 -0
  90. package/dist/src/tools/shell.js.map +1 -0
  91. package/dist/src/tools/types.d.ts +25 -0
  92. package/dist/src/tools/types.d.ts.map +1 -0
  93. package/dist/src/tools/types.js +54 -0
  94. package/dist/src/tools/types.js.map +1 -0
  95. package/dist/src/utils/logger.d.ts +10 -0
  96. package/dist/src/utils/logger.d.ts.map +1 -0
  97. package/dist/src/utils/logger.js +25 -0
  98. package/dist/src/utils/logger.js.map +1 -0
  99. package/dist/src/utils/prompt.d.ts +3 -0
  100. package/dist/src/utils/prompt.d.ts.map +1 -0
  101. package/dist/src/utils/prompt.js +26 -0
  102. package/dist/src/utils/prompt.js.map +1 -0
  103. package/package.json +55 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 scanton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # Phase2S
2
+
3
+ Phase2S is a personal AI coding assistant you run in your terminal. You type questions about your code, ask it to review files, debug problems, or implement a feature — and it answers using your existing ChatGPT subscription or OpenAI API key.
4
+
5
+ Think of it as a slash-command layer on top of AI. Instead of typing "please review this file for security issues and flag each problem with a severity level", you type `/review src/core/auth.ts` and get a structured, consistent answer every time.
6
+
7
+ ```
8
+ you > /review src/core/agent.ts
9
+ assistant > Reviewing src/core/agent.ts...
10
+
11
+ CRIT: The `maxTurns` check runs after tool execution, not before.
12
+ An LLM that loops tool calls can exceed the limit by one turn.
13
+
14
+ WARN: `getConversation()` returns the live object, not a copy.
15
+ Callers that mutate it will corrupt the conversation state.
16
+
17
+ NIT: Inline comment on line 47 is stale — describes old batch behavior.
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Quick install
23
+
24
+ Requires [Node.js](https://nodejs.org) >= 20.
25
+
26
+ **Option A: ChatGPT Plus or Pro subscription (recommended)**
27
+
28
+ No API key, no per-token billing. All 29 skills work.
29
+
30
+ ```bash
31
+ npm install -g @openai/codex @scanton/phase2s
32
+ codex auth
33
+ phase2s
34
+ ```
35
+
36
+ **Option B: OpenAI API key**
37
+
38
+ Unlocks token-by-token streaming and model-per-skill routing.
39
+
40
+ ```bash
41
+ npm install -g @scanton/phase2s
42
+ export OPENAI_API_KEY=sk-your-key-here
43
+ export PHASE2S_PROVIDER=openai-api
44
+ phase2s
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Quick start
50
+
51
+ Once you're at the `you >` prompt:
52
+
53
+ ```
54
+ you > /review src/core/agent.ts — code review with CRIT/WARN/NIT tagging
55
+ you > /diff — review all uncommitted changes
56
+ you > /satori add rate limiting — implement + test + retry until green
57
+ you > /health — code quality score (tests, types, lint)
58
+ you > /remember — save a project convention to memory
59
+ ```
60
+
61
+ One-shot mode (no REPL):
62
+
63
+ ```bash
64
+ phase2s run "explain what src/core/agent.ts does"
65
+ ```
66
+
67
+ Resume your last session:
68
+
69
+ ```bash
70
+ phase2s --resume
71
+ ```
72
+
73
+ ---
74
+
75
+ ## What's included
76
+
77
+ 29 built-in skills across 6 categories. A few highlights:
78
+
79
+ - `/satori` — implement a task, run `npm test`, retry on failure (up to 3 times). Stops when tests are green, not when the model thinks it's done.
80
+ - `/consensus-plan` — planner + architect + critic passes before producing a plan. Catches the errors that only show up in implementation.
81
+ - `/deep-specify` — Socratic interview before writing code. Saves a spec with Intent, Boundaries, and Success criteria.
82
+ - `/debug` — reproduce, isolate, fix, and verify a bug end-to-end.
83
+ - `/remember` — save project conventions to persistent memory. Injected into every future session automatically.
84
+ - `/skill` — create a new `/command` from inside Phase2S. Three questions, no YAML editing.
85
+ - `/land-and-deploy` — push, open a PR, merge it, wait for CI, confirm the land. Picks up where `/ship` leaves off.
86
+
87
+ List everything:
88
+
89
+ ```bash
90
+ phase2s skills
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Docs
96
+
97
+ - [Getting started](docs/getting-started.md) — full setup walkthrough, first session, first skill call
98
+ - [Skills reference](docs/skills.md) — all 29 skills with examples and arguments
99
+ - [Workflows](docs/workflows.md) — real development sessions: feature, debug, review, weekly rhythm
100
+ - [Memory and persistence](docs/memory.md) — session resume, `/remember`, what Phase2S writes to disk
101
+ - [Writing custom skills](docs/writing-skills.md) — SKILL.md format, frontmatter fields, examples
102
+ - [Advanced](docs/advanced.md) — streaming, tool loop, model routing (requires API key)
103
+ - [Claude Code integration](docs/claude-code.md) — MCP server setup, cross-model adversarial review
104
+ - [Configuration](docs/configuration.md) — `.phase2s.yaml` reference, environment variables
105
+
106
+ ---
107
+
108
+ ## Roadmap
109
+
110
+ - [x] Codex CLI provider (uses ChatGPT subscription, no API key required)
111
+ - [x] 29 built-in skills across 6 categories
112
+ - [x] SKILL.md compatibility with `~/.codex/skills/`
113
+ - [x] Smart skill argument parsing (file paths vs. context strings)
114
+ - [x] File sandbox: tools reject paths outside the project directory, including symlink escapes
115
+ - [x] 208 tests covering all tools, core modules, and agent integration (`npm test`)
116
+ - [x] CI: runs `npm test` on every push and PR (GitHub Actions, Node.js 22)
117
+ - [x] Direct OpenAI API provider with live tool calling
118
+ - [x] Streaming output — responses stream token-by-token
119
+ - [x] `npm install -g @scanton/phase2s`
120
+ - [x] Session persistence — auto-save after each turn, `--resume` to continue
121
+ - [x] Model-per-skill routing — `fast_model` / `smart_model` tiers in `.phase2s.yaml`
122
+ - [x] Satori persistent execution — retry loop with shell verification, context snapshots, attempt logs
123
+ - [x] Consensus planning — planner + architect + critic passes
124
+ - [x] Claude Code MCP integration — all skills available as Claude Code tools via `phase2s mcp`
125
+ - [x] `/adversarial` skill — cross-model adversarial review with structured output
126
+ - [x] Persistent memory — `/remember` saves learnings to `.phase2s/memory/learnings.jsonl`
127
+ - [x] `/skill` meta-skill — create new skills from inside Phase2S
128
+ - [x] Session file security — session files written with `mode: 0o600` (owner-only)
129
+ - [x] `/land-and-deploy` skill — push, PR, CI wait, merge, deploy confirmation via `gh` CLI
130
+ - [ ] Real Codex streaming (JSONL stdout parsing)
131
+ - [ ] npm publish
132
+
133
+ ---
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=phase2s.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phase2s.d.ts","sourceRoot":"","sources":["../../bin/phase2s.ts"],"names":[],"mappings":""}
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli/index.js";
3
+ main().catch((err) => {
4
+ console.error(err);
5
+ process.exit(1);
6
+ });
7
+ //# sourceMappingURL=phase2s.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phase2s.js","sourceRoot":"","sources":["../../bin/phase2s.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAE3C,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACnB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare function main(argv?: string[]): Promise<void>;
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/cli/index.ts"],"names":[],"mappings":"AA8CA,wBAAsB,IAAI,CAAC,IAAI,GAAE,MAAM,EAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmEvE"}
@@ -0,0 +1,499 @@
1
+ import { Command } from "commander";
2
+ import { createInterface } from "node:readline";
3
+ import { access, constants, readdir } from "node:fs/promises";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { writeFile, mkdir } from "node:fs/promises";
6
+ import { execSync } from "node:child_process";
7
+ import { join, resolve } from "node:path";
8
+ import chalk from "chalk";
9
+ import { loadConfig } from "../core/config.js";
10
+ import { Agent } from "../core/agent.js";
11
+ import { Conversation } from "../core/conversation.js";
12
+ import { loadLearnings, formatLearningsForPrompt } from "../core/memory.js";
13
+ import { loadAllSkills } from "../skills/index.js";
14
+ import { log } from "../utils/logger.js";
15
+ const VERSION = "0.13.0";
16
+ /** Directory for session auto-saves. */
17
+ const SESSION_DIR = join(process.cwd(), ".phase2s", "sessions");
18
+ /** Path for today's session file. */
19
+ function todaySessionPath() {
20
+ const d = new Date();
21
+ const datePart = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
22
+ return join(SESSION_DIR, `${datePart}.json`);
23
+ }
24
+ /**
25
+ * Find the most recent session file in SESSION_DIR.
26
+ * Returns null if no session files exist.
27
+ */
28
+ async function findLatestSession() {
29
+ let entries;
30
+ try {
31
+ entries = await readdir(SESSION_DIR);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ // Only match YYYY-MM-DD.json filenames so stray .json files don't become sessions.
37
+ const sessions = entries
38
+ .filter((e) => /^\d{4}-\d{2}-\d{2}\.json$/.test(e))
39
+ .sort()
40
+ .reverse();
41
+ return sessions.length > 0 ? join(SESSION_DIR, sessions[0]) : null;
42
+ }
43
+ export async function main(argv = process.argv) {
44
+ const program = new Command();
45
+ program
46
+ .name("phase2s")
47
+ .description("AI programming harness with multi-model support")
48
+ .version(VERSION)
49
+ .option("-p, --provider <provider>", "LLM provider (codex-cli | openai-api)")
50
+ .option("-m, --model <model>", "Model to use")
51
+ .option("--system <prompt>", "Custom system prompt")
52
+ .option("--resume", "Resume the most recent session");
53
+ // Default command: interactive REPL
54
+ program
55
+ .command("chat", { isDefault: true })
56
+ .description("Start an interactive chat session")
57
+ .action(async () => {
58
+ const opts = program.opts();
59
+ const config = await loadConfig({
60
+ provider: opts.provider,
61
+ model: opts.model,
62
+ systemPrompt: opts.system,
63
+ });
64
+ await interactiveMode(config, { resume: !!opts.resume });
65
+ });
66
+ // One-shot mode
67
+ program
68
+ .command("run <prompt>")
69
+ .description("Run a single prompt and exit")
70
+ .action(async (prompt) => {
71
+ const opts = program.opts();
72
+ const config = await loadConfig({
73
+ provider: opts.provider,
74
+ model: opts.model,
75
+ systemPrompt: opts.system,
76
+ });
77
+ await oneShotMode(config, prompt);
78
+ });
79
+ // MCP server — exposes all Phase2S skills as Claude Code tools
80
+ program
81
+ .command("mcp")
82
+ .description("Start Phase2S as an MCP server for Claude Code integration")
83
+ .action(async () => {
84
+ const { runMCPServer } = await import("../mcp/server.js");
85
+ await runMCPServer(process.cwd());
86
+ });
87
+ // List available skills
88
+ program
89
+ .command("skills")
90
+ .description("List available skills")
91
+ .action(async () => {
92
+ const skills = await loadAllSkills();
93
+ if (skills.length === 0) {
94
+ log.info("No skills found. Add skills to .phase2s/skills/ or ~/.phase2s/skills/");
95
+ return;
96
+ }
97
+ console.log(chalk.bold("\nAvailable skills:\n"));
98
+ for (const skill of skills) {
99
+ console.log(` ${chalk.cyan("/" + skill.name)} — ${skill.description || "(no description)"}`);
100
+ }
101
+ console.log();
102
+ });
103
+ await program.parseAsync(argv);
104
+ }
105
+ const UNDERSPEC_WORD_THRESHOLD = 15;
106
+ function isUnderspecified(prompt) {
107
+ const words = prompt.trim().split(/\s+/).filter(Boolean);
108
+ const hasFilePath = /[./]/.test(prompt);
109
+ return words.length < UNDERSPEC_WORD_THRESHOLD && !hasFilePath;
110
+ }
111
+ function makeSlug(prompt) {
112
+ return prompt
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9\s-]/g, "")
115
+ .trim()
116
+ .replace(/\s+/g, "-")
117
+ .slice(0, 40);
118
+ }
119
+ async function writeContextSnapshot(prompt, config) {
120
+ const dir = resolve(".phase2s", "context");
121
+ await mkdir(dir, { recursive: true });
122
+ const slug = makeSlug(prompt);
123
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16);
124
+ const filename = `${ts}-${slug}.md`;
125
+ const filePath = resolve(dir, filename);
126
+ let gitLog = "";
127
+ let gitDiff = "";
128
+ let branch = "unknown";
129
+ try {
130
+ branch = execSync("git branch --show-current", { encoding: "utf-8" }).trim();
131
+ gitLog = execSync("git log --oneline -5", { encoding: "utf-8" }).trim();
132
+ gitDiff = execSync("git diff --stat HEAD", { encoding: "utf-8" }).trim();
133
+ }
134
+ catch {
135
+ // Not a git repo or git not available
136
+ }
137
+ const content = [
138
+ `# Context Snapshot: ${slug}`,
139
+ `Date: ${new Date().toISOString()}`,
140
+ `Branch: ${branch}`,
141
+ `Task: ${prompt.slice(0, 200)}`,
142
+ "",
143
+ "## Codebase Context",
144
+ gitLog || "(no git log)",
145
+ "",
146
+ gitDiff || "(no uncommitted changes)",
147
+ "",
148
+ "## Success Criteria",
149
+ `Passes: ${config.verifyCommand ?? "npm test"}`,
150
+ "",
151
+ "## Unknowns",
152
+ "[to be filled by agent during first pass]",
153
+ ].join("\n");
154
+ await writeFile(filePath, content, "utf-8");
155
+ log.dim(`Context snapshot: .phase2s/context/${filename}`);
156
+ }
157
+ async function writeSatoriLog(slug, startedAt, result, config, allAttempts) {
158
+ const dir = resolve(".phase2s", "satori");
159
+ await mkdir(dir, { recursive: true });
160
+ const filePath = resolve(dir, `${slug}.json`);
161
+ const log_data = {
162
+ taskSlug: slug,
163
+ startedAt,
164
+ completedAt: new Date().toISOString(),
165
+ maxRetries: allAttempts.length,
166
+ verifyCommand: config.verifyCommand ?? "npm test",
167
+ attempts: allAttempts.map((a) => ({
168
+ attempt: a.attempt,
169
+ passed: a.passed,
170
+ exitCode: a.passed ? 0 : 1,
171
+ failureLines: a.verifyOutput
172
+ .split("\n")
173
+ .filter((l) => /fail|error|assert/i.test(l))
174
+ .slice(0, 5),
175
+ })),
176
+ finalStatus: result.passed ? "passed" : "failed",
177
+ };
178
+ await writeFile(filePath, JSON.stringify(log_data, null, 2), "utf-8");
179
+ }
180
+ /**
181
+ * Interactive REPL.
182
+ *
183
+ * Uses a manual event-queue pattern (rl.on('line')) rather than the
184
+ * readline async iterator. The async iterator has a known issue where
185
+ * it terminates if the event loop drains while awaiting between turns —
186
+ * which is exactly what happens while the LLM is streaming.
187
+ */
188
+ async function interactiveMode(config, opts = {}) {
189
+ if (!(await checkCodexBinary(config)))
190
+ process.exit(1);
191
+ if (!checkOpenAIKey(config))
192
+ process.exit(1);
193
+ // --resume: load most recent session
194
+ let resumedConversation;
195
+ if (opts.resume) {
196
+ const sessionPath = await findLatestSession();
197
+ if (sessionPath) {
198
+ try {
199
+ resumedConversation = await Conversation.load(sessionPath);
200
+ console.log(chalk.dim(`Resuming session from ${sessionPath} (${resumedConversation.length} messages)\n`));
201
+ }
202
+ catch {
203
+ console.log(chalk.yellow("Warning: Could not load previous session. Starting fresh.\n"));
204
+ }
205
+ }
206
+ else {
207
+ console.log(chalk.yellow("No previous session found. Starting fresh.\n"));
208
+ }
209
+ }
210
+ console.log(chalk.bold(`\nPhase2S v${VERSION}`));
211
+ console.log(chalk.dim("Type your message and press Enter. Type /quit to exit.\n"));
212
+ // Load persistent memory learnings from .phase2s/memory/learnings.jsonl
213
+ const learningsList = await loadLearnings(process.cwd());
214
+ const learningsStr = formatLearningsForPrompt(learningsList);
215
+ if (learningsList.length > 0) {
216
+ log.dim(`Learnings: ${learningsList.length} ${learningsList.length === 1 ? "entry" : "entries"} from .phase2s/memory/`);
217
+ }
218
+ const agent = new Agent({ config, conversation: resumedConversation, learnings: learningsStr });
219
+ const skills = await loadAllSkills();
220
+ // Session auto-save path — today's file
221
+ const sessionPath = todaySessionPath();
222
+ // Ensure stdin is open and stays open for the full session
223
+ process.stdin.resume();
224
+ process.stdin.setEncoding("utf8");
225
+ const rl = createInterface({
226
+ input: process.stdin,
227
+ output: process.stdout,
228
+ terminal: true,
229
+ });
230
+ // Manual async queue: lines go in via rl.on('line'), come out via nextLine()
231
+ const lineQueue = [];
232
+ let pendingResolve = null;
233
+ let isOpen = true;
234
+ rl.on("line", (line) => {
235
+ if (pendingResolve) {
236
+ const resolve = pendingResolve;
237
+ pendingResolve = null;
238
+ resolve(line);
239
+ }
240
+ else {
241
+ lineQueue.push(line);
242
+ }
243
+ });
244
+ rl.on("close", () => {
245
+ isOpen = false;
246
+ if (pendingResolve) {
247
+ pendingResolve(null);
248
+ pendingResolve = null;
249
+ }
250
+ });
251
+ rl.on("SIGINT", () => {
252
+ process.stdout.write("\n");
253
+ // Synchronous save before exit — async saveSession() can't complete after process.exit().
254
+ try {
255
+ mkdirSync(resolve(sessionPath, ".."), { recursive: true });
256
+ writeFileSync(sessionPath, JSON.stringify(agent.getConversation().getMessages(), null, 2), { encoding: "utf-8", mode: 0o600 });
257
+ }
258
+ catch {
259
+ // Best-effort — don't block exit on save failure
260
+ }
261
+ log.info("Goodbye!");
262
+ rl.close();
263
+ process.exit(0);
264
+ });
265
+ /** Wait for the next line of input. Returns null if stdin closes. */
266
+ const nextLine = () => {
267
+ if (lineQueue.length > 0)
268
+ return Promise.resolve(lineQueue.shift());
269
+ if (!isOpen)
270
+ return Promise.resolve(null);
271
+ return new Promise((resolve) => {
272
+ pendingResolve = resolve;
273
+ });
274
+ };
275
+ /** Save the current conversation to today's session file (best-effort). */
276
+ const saveSession = async () => {
277
+ try {
278
+ // mode 0o600: session files may contain code, file paths, or secrets —
279
+ // restrict to owner-only to prevent world-readable exposure on multi-user systems.
280
+ await agent.getConversation().save(sessionPath, 0o600);
281
+ }
282
+ catch {
283
+ // Best-effort — session save failures don't interrupt the user
284
+ }
285
+ };
286
+ const writePrompt = () => process.stdout.write(chalk.green("you > "));
287
+ // Main REPL loop
288
+ while (true) {
289
+ writePrompt();
290
+ const line = await nextLine();
291
+ if (line === null)
292
+ break; // stdin closed cleanly
293
+ const trimmed = line.trim();
294
+ if (!trimmed)
295
+ continue;
296
+ if (trimmed === "/quit" || trimmed === "/exit") {
297
+ log.info("Goodbye!");
298
+ rl.close();
299
+ process.exit(0);
300
+ }
301
+ if (trimmed === "/help") {
302
+ printHelp(skills);
303
+ continue;
304
+ }
305
+ // Skill invocation — batch mode (no streaming, keeps the "Running..." indicator)
306
+ if (trimmed.startsWith("/")) {
307
+ const skillName = trimmed.slice(1).split(" ")[0];
308
+ const skill = skills.find((s) => s.name === skillName);
309
+ if (skill) {
310
+ const args = trimmed.slice(1 + skillName.length).trim();
311
+ const expanded = skill.promptTemplate + buildSkillContext(args);
312
+ // Underspecification gate
313
+ if (config.requireSpecification && !args.startsWith("force:")) {
314
+ const checkPrompt = args || trimmed;
315
+ if (isUnderspecified(checkPrompt)) {
316
+ console.log(chalk.yellow("⚠ This prompt seems underspecified. Add more detail, or prefix with 'force:' to proceed."));
317
+ continue;
318
+ }
319
+ }
320
+ const safeArgs = args.startsWith("force:") ? args.slice("force:".length).trim() : args;
321
+ const finalExpanded = skill.promptTemplate + buildSkillContext(safeArgs);
322
+ process.stdout.write(chalk.dim(`Running /${skill.name}${safeArgs ? ` on: ${safeArgs}` : ""}...\n`));
323
+ // Satori mode: skill declares retries > 0
324
+ if (skill.retries && skill.retries > 0) {
325
+ const slug = makeSlug(expanded.slice(0, 100));
326
+ const startedAt = new Date().toISOString();
327
+ const attempts = [];
328
+ try {
329
+ const response = await agent.run(finalExpanded, {
330
+ modelOverride: skill.model,
331
+ maxRetries: skill.retries,
332
+ verifyCommand: config.verifyCommand,
333
+ preRun: () => writeContextSnapshot(expanded, config),
334
+ postRun: async (result) => {
335
+ attempts.push(result);
336
+ await writeSatoriLog(slug, startedAt, result, config, attempts);
337
+ },
338
+ });
339
+ console.log(chalk.bold("\nassistant > ") + response + "\n");
340
+ await saveSession();
341
+ }
342
+ catch (err) {
343
+ log.error(err instanceof Error ? err.message : String(err));
344
+ }
345
+ }
346
+ else {
347
+ // Normal skill run
348
+ try {
349
+ const response = await agent.run(finalExpanded, { modelOverride: skill.model });
350
+ console.log(chalk.bold("\nassistant > ") + response + "\n");
351
+ await saveSession();
352
+ }
353
+ catch (err) {
354
+ log.error(err instanceof Error ? err.message : String(err));
355
+ }
356
+ }
357
+ continue;
358
+ }
359
+ }
360
+ // Normal message — stream deltas as they arrive
361
+ process.stdout.write(chalk.bold("\nassistant > "));
362
+ try {
363
+ await agent.run(trimmed, { onDelta: (chunk) => process.stdout.write(chunk) });
364
+ process.stdout.write("\n\n");
365
+ await saveSession();
366
+ }
367
+ catch (err) {
368
+ process.stdout.write("\n");
369
+ log.error(err instanceof Error ? err.message : String(err));
370
+ }
371
+ }
372
+ }
373
+ /**
374
+ * Check that the configured codex binary is executable before starting.
375
+ * Prints a clear, actionable error message if not found — rather than letting
376
+ * the user hit a cryptic ENOENT from spawn() mid-session.
377
+ */
378
+ async function checkCodexBinary(config) {
379
+ if (config.provider !== "codex-cli")
380
+ return true;
381
+ const codexPath = config.codexPath;
382
+ // If it looks like an absolute/relative path, check directly
383
+ if (codexPath.startsWith("/") || codexPath.startsWith(".")) {
384
+ try {
385
+ await access(resolve(codexPath), constants.X_OK);
386
+ return true;
387
+ }
388
+ catch {
389
+ // fall through to error
390
+ }
391
+ }
392
+ else {
393
+ // Search PATH entries
394
+ const pathDirs = (process.env.PATH ?? "").split(":");
395
+ for (const dir of pathDirs) {
396
+ try {
397
+ await access(resolve(dir, codexPath), constants.X_OK);
398
+ return true;
399
+ }
400
+ catch {
401
+ // not in this dir
402
+ }
403
+ }
404
+ }
405
+ console.error(chalk.red(`\n✗ "${codexPath}" not found or not executable.\n`) +
406
+ chalk.dim(" Install Codex CLI: npm install -g @openai/codex\n" +
407
+ " Or switch provider: PHASE2S_PROVIDER=openai-api phase2s\n" +
408
+ " Or set path: PHASE2S_CODEX_PATH=/path/to/codex phase2s\n"));
409
+ return false;
410
+ }
411
+ /**
412
+ * Pre-flight check for the openai-api provider: verify API key is set
413
+ * before opening the REPL, so the user gets a clear error at startup
414
+ * rather than mid-session.
415
+ */
416
+ function checkOpenAIKey(config) {
417
+ if (config.provider !== "openai-api")
418
+ return true;
419
+ if (config.apiKey)
420
+ return true;
421
+ console.error(chalk.red("\n✗ OpenAI API key not found.\n") +
422
+ chalk.dim(" Set it: export OPENAI_API_KEY=sk-...\n" +
423
+ " Or add: apiKey: sk-... in .phase2s.yaml\n"));
424
+ return false;
425
+ }
426
+ async function oneShotMode(config, prompt) {
427
+ if (!(await checkCodexBinary(config)))
428
+ process.exit(1);
429
+ if (!checkOpenAIKey(config))
430
+ process.exit(1);
431
+ // Load persistent memory learnings from .phase2s/memory/learnings.jsonl
432
+ const learningsList = await loadLearnings(process.cwd());
433
+ const learningsStr = formatLearningsForPrompt(learningsList);
434
+ if (learningsList.length > 0) {
435
+ log.dim(`Learnings: ${learningsList.length} ${learningsList.length === 1 ? "entry" : "entries"} from .phase2s/memory/`);
436
+ }
437
+ const agent = new Agent({ config, learnings: learningsStr });
438
+ let hasOutput = false;
439
+ try {
440
+ const result = await agent.run(prompt, { onDelta: (chunk) => { process.stdout.write(chunk); hasOutput = true; } });
441
+ if (!hasOutput) {
442
+ // Fallback: tool-only path with no final text (rare in practice)
443
+ process.stdout.write(result);
444
+ }
445
+ process.stdout.write("\n");
446
+ }
447
+ catch (err) {
448
+ log.error(err instanceof Error ? err.message : String(err));
449
+ process.exit(1);
450
+ }
451
+ }
452
+ /**
453
+ * Build context section to append to a skill's prompt template based on
454
+ * the arguments the user typed after the skill name.
455
+ *
456
+ * `/review` → no context appended
457
+ * `/review src/core/agent.ts` → "Focus on this file: src/core/agent.ts"
458
+ * `/review src/core/agent.ts src/cli/index.ts` → "Focus on these files: ..."
459
+ * `/investigate why does the REPL exit` → "Additional context: why does..."
460
+ */
461
+ function buildSkillContext(args) {
462
+ if (!args)
463
+ return "";
464
+ // Split on whitespace and check if all tokens look like file paths
465
+ // (contain a / or . suggesting a path/extension, no special chars)
466
+ const tokens = args.split(/\s+/).filter(Boolean);
467
+ const looksLikeFilePath = (s) => /^[./~]/.test(s) || /\.\w{1,6}$/.test(s);
468
+ const filePaths = tokens.filter(looksLikeFilePath);
469
+ const rest = tokens.filter((t) => !looksLikeFilePath(t)).join(" ");
470
+ const parts = [];
471
+ if (filePaths.length === 1) {
472
+ parts.push(`\n\nFocus on this file: ${filePaths[0]}`);
473
+ }
474
+ else if (filePaths.length > 1) {
475
+ parts.push(`\n\nFocus on these files:\n${filePaths.map((f) => ` - ${f}`).join("\n")}`);
476
+ }
477
+ if (rest) {
478
+ parts.push(`\n\nAdditional context: ${rest}`);
479
+ }
480
+ // If no file paths detected, treat whole thing as context
481
+ if (filePaths.length === 0) {
482
+ return `\n\nAdditional context: ${args}`;
483
+ }
484
+ return parts.join("");
485
+ }
486
+ function printHelp(skills) {
487
+ console.log(chalk.bold("\nPhase2S Commands:\n"));
488
+ console.log(" /help — Show this help");
489
+ console.log(" /quit — Exit the session");
490
+ console.log(" /exit — Exit the session");
491
+ if (skills.length > 0) {
492
+ console.log(chalk.bold("\nSkills:"));
493
+ for (const skill of skills) {
494
+ console.log(` /${skill.name} — ${skill.description || "(no description)"}`);
495
+ }
496
+ }
497
+ console.log();
498
+ }
499
+ //# sourceMappingURL=index.js.map