docforge 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e3c353d3c8c6d3270907b4cda066be13c12f8dc20ee5428ccb9a9705a817149a
4
+ data.tar.gz: 2ca10538751acb9e9a6ae7789e0a60b06352e190add675ddcd4028f736b3ca6f
5
+ SHA512:
6
+ metadata.gz: 4a884fd16024bae5ebc2d7533dab5244f9512cbd5d9bf12fa57500fc668f6b0c4285daaac12bd63aaa4b0176cffa8613d4bf4ed7eba16278df783abd6717ac8e
7
+ data.tar.gz: 00ef52d132f593473526bb029eff9eef4ef0ba252980239f89c4eda2c121b2311e22df0a08a49a0e98a4852b7444780fdce325f0c511fb87844918dcd8e63427
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem will be documented in this file.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-05-15
9
+
10
+ ### Added
11
+ - First public release.
12
+ - `docforge generate FOLDER` — reads `PRD.md` + `SPEC.md` from a feature folder, runs them through Claude with a structured feature-brief prompt, renders a styled `.docx`.
13
+ - `docforge init` — drops a starter `.docforge.yml` in the current directory.
14
+ - `docforge config` — prints the resolved configuration and which config files were loaded.
15
+ - Layered config: env vars > `./.docforge.yml` (or `--config PATH`) > `~/.docforge.yml` > built-in defaults.
16
+ - Configurable input filenames, system prompt, interview questions, output directory, author, and model.
17
+ - Tool-use forcing on the Anthropic Messages API to guarantee structurally valid JSON responses.
18
+ - Per-feature response cache (`.brief-cache.json`) so re-renders don't re-spend tokens. Pass `--fresh` to bypass.
19
+ - `--dry-run` flag to preview the prompt and skip the API call.
20
+ - `--no-interview` flag to skip the interactive interview.
21
+ - Spinner during API calls; raw response dumped to `/tmp/docforge-last-response.json` on every call for recovery.
22
+ - Caracal-based `.docx` renderer with a "modern tech" visual style: Calibri, blue accent, table-heavy layout, callout for the pull quote.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rao Usama Abid
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # docforge
2
+
3
+ Generate client-facing feature briefs (`.docx`) from a PRD + SPEC pair using Claude.
4
+
5
+ `docforge` reads a folder containing your product requirements (`PRD.md`) and technical spec (`SPEC.md`), interviews you with 3–5 short questions, calls the Anthropic API with a saved "feature brief writer" prompt, and emits a styled Word document ready to share with non-technical clients, portfolio readers, or stakeholders.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ gem install docforge
11
+ ```
12
+
13
+ Requires Ruby 3.2+ and an Anthropic API key.
14
+
15
+ ## Quickstart
16
+
17
+ ```bash
18
+ # 1. Set your API key
19
+ export ANTHROPIC_API_KEY=sk-ant-...
20
+
21
+ # 2. Lay out a feature folder
22
+ mkdir -p ~/Desktop/MyFeature
23
+ cp /path/to/PRD.md ~/Desktop/MyFeature/
24
+ cp /path/to/SPEC.md ~/Desktop/MyFeature/
25
+
26
+ # 3. Generate the brief
27
+ docforge generate ~/Desktop/MyFeature
28
+ ```
29
+
30
+ The first run interviews you, calls the API, caches the response next to the inputs, and writes a `.docx` to `~/Desktop/Portfolio/Features/` (or wherever you've configured).
31
+
32
+ Re-runs read from the cache instantly — no further API spend until you pass `--fresh`.
33
+
34
+ ## Per-project configuration
35
+
36
+ `docforge` reads layered config with this precedence (highest first):
37
+
38
+ 1. **Env vars** — `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `DOCFORGE_OUTPUT_DIR`, `DOCFORGE_AUTHOR`
39
+ 2. **Project config** — `./.docforge.yml` (or `--config PATH`)
40
+ 3. **Global config** — `~/.docforge.yml`
41
+ 4. **Built-in defaults**
42
+
43
+ Generate a starter config:
44
+
45
+ ```bash
46
+ docforge init
47
+ ```
48
+
49
+ Example `.docforge.yml`:
50
+
51
+ ```yaml
52
+ model: claude-sonnet-4-5
53
+ output_dir: ~/Desktop/Portfolio/Features
54
+ author: Your Name
55
+
56
+ # Some projects use different filenames
57
+ input_files:
58
+ prd: requirements.md
59
+ spec: design.md
60
+ notes: thoughts.md
61
+
62
+ # Custom prompt for this domain (relative to this yaml)
63
+ system_prompt: ./prompts/feature_brief.md
64
+
65
+ # Replace the default 5 interview questions
66
+ interview:
67
+ - "Who is the human user of this feature?"
68
+ - "What broke before this shipped?"
69
+ - "What's the hardest tradeoff you made?"
70
+ ```
71
+
72
+ ## Commands
73
+
74
+ ```bash
75
+ docforge init # write a starter .docforge.yml
76
+ docforge config # show resolved config + loaded files
77
+ docforge generate FOLDER # generate brief from a feature folder
78
+ docforge generate FOLDER --fresh # ignore cache, re-call API
79
+ docforge generate FOLDER --dry-run # show what would happen; no API call
80
+ docforge generate FOLDER --no-interview # skip the interview
81
+ docforge -c path/to/config.yml generate FOLDER # explicit config path
82
+ ```
83
+
84
+ ## Caching
85
+
86
+ After a successful API call, `docforge` writes `.brief-cache.json` next to your PRD/SPEC. Subsequent renders read from cache — useful for iterating on styling without burning tokens. Delete the file or pass `--fresh` to re-call.
87
+
88
+ ## Folder layout
89
+
90
+ ```
91
+ <feature_folder>/
92
+ PRD.md # required (name configurable)
93
+ SPEC.md # required (name configurable)
94
+ notes.md # optional — your "what I'm proud of" notes
95
+ _assets/ # optional — screenshots, diagrams
96
+ 01-overview.png
97
+ 02-flow.png
98
+ .brief-cache.json # auto-generated, contains the model response
99
+ ```
100
+
101
+ ## Environment variables
102
+
103
+ | Variable | Purpose | Default |
104
+ |---|---|---|
105
+ | `ANTHROPIC_API_KEY` | **Required.** Anthropic API key. | — |
106
+ | `ANTHROPIC_MODEL` | Model id. | `claude-sonnet-4-5` |
107
+ | `DOCFORGE_OUTPUT_DIR` | Where the `.docx` lands. | `~/Desktop/Portfolio/Features` |
108
+ | `DOCFORGE_AUTHOR` | Byline used in the brief. | `You` |
109
+
110
+ ## License
111
+
112
+ MIT — see [LICENSE](LICENSE).
data/bin/docforge ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "docforge"
5
+ require "docforge/cli"
6
+
7
+ Docforge::CLI.start(ARGV)
data/docforge.gemspec ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/docforge/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "docforge"
7
+ spec.version = Docforge::VERSION
8
+ spec.authors = ["Rao Usama Abid"] # TODO before publish
9
+ spec.email = ["usamaroar786@gmail.com"] # TODO before publish
10
+ spec.summary = "Generate client-facing feature briefs (.docx) from a PRD + SPEC pair using Claude."
11
+ spec.description = <<~DESC
12
+ docforge reads a feature folder containing a PRD and a SPEC, calls
13
+ the Anthropic API with a saved feature-brief writer prompt, and emits
14
+ a styled .docx ready to share with non-technical readers (clients,
15
+ portfolios, internal stakeholders).
16
+
17
+ Configurable per-project via .docforge.yml: input filenames, system
18
+ prompt, interview questions, output directory, and model.
19
+ DESC
20
+ spec.license = "MIT"
21
+
22
+ spec.required_ruby_version = ">= 3.2.0"
23
+
24
+ gh_user = "RaoUsamaAbid" # TODO before publish
25
+ spec.homepage = "https://github.com/#{gh_user}/docforge"
26
+ spec.metadata["homepage_uri"] = spec.homepage
27
+ spec.metadata["source_code_uri"] = spec.homepage
28
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
29
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
30
+ spec.metadata["rubygems_mfa_required"] = "true"
31
+
32
+ spec.files = Dir[
33
+ "lib/**/*.rb",
34
+ "bin/*",
35
+ "prompts/*.md",
36
+ "README.md",
37
+ "LICENSE",
38
+ "CHANGELOG.md",
39
+ "docforge.gemspec"
40
+ ]
41
+ spec.bindir = "bin"
42
+ spec.executables = ["docforge"]
43
+ spec.require_paths = ["lib"]
44
+
45
+ spec.add_dependency "thor", "~> 1.3" # CLI
46
+ spec.add_dependency "caracal", "~> 1.4" # .docx generation
47
+ spec.add_dependency "httparty", "~> 0.22" # Anthropic API calls
48
+ spec.add_dependency "tty-prompt", "~> 0.23" # interview prompts
49
+ spec.add_dependency "tty-spinner", "~> 0.9" # loader during API call
50
+
51
+ spec.add_development_dependency "rake", "~> 13.0"
52
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docforge
4
+ # Typed wrapper around the JSON payload the model returns.
5
+ # Gives the renderer a consistent API and lets us validate up front
6
+ # instead of crashing mid-render with a NoMethodError on `nil`.
7
+ class Brief
8
+ REQUIRED_KEYS = %w[
9
+ title value_statement problem_paragraph
10
+ capabilities worked_example design_decisions
11
+ why_hard_paragraph capabilities_table
12
+ sharable
13
+ ].freeze
14
+
15
+ attr_reader :data
16
+
17
+ def initialize(data)
18
+ @data = data
19
+ validate!
20
+ end
21
+
22
+ def title; @data["title"]; end
23
+ def value_statement; @data["value_statement"]; end
24
+ def pull_quote_italic; @data["pull_quote_italic"]; end
25
+ def metadata; @data["metadata"] || {}; end
26
+ def problem; @data["problem_paragraph"]; end
27
+ def capabilities; @data["capabilities"] || []; end
28
+ def worked_example; @data["worked_example"] || {}; end
29
+ def design_decisions; @data["design_decisions"] || []; end
30
+ def why_hard; @data["why_hard_paragraph"]; end
31
+ def capabilities_table; @data["capabilities_table"] || []; end
32
+ def numbers; @data["numbers"] || []; end
33
+ def future_extensions; @data["future_extensions"] || []; end
34
+ def stack_and_credits; @data["stack_and_credits"] || {}; end
35
+ def sharable; @data["sharable"] || {}; end
36
+
37
+ private
38
+
39
+ def validate!
40
+ missing = REQUIRED_KEYS - @data.keys
41
+ return if missing.empty?
42
+
43
+ raise InputError,
44
+ "Model response missing required keys: #{missing.join(', ')}.\n" \
45
+ "Got keys: #{@data.keys.inspect}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "tty-prompt"
5
+ require "tty-spinner"
6
+ require "json"
7
+ require "time"
8
+ require "fileutils"
9
+
10
+ module Docforge
11
+ class CLI < Thor
12
+ package_name "docforge"
13
+
14
+ class_option :config, type: :string, aliases: "-c",
15
+ desc: "Path to a .docforge.yml config (overrides ./.docforge.yml)"
16
+
17
+ def self.exit_on_failure?
18
+ true
19
+ end
20
+
21
+ desc "version", "Print the gem version"
22
+ def version
23
+ puts Docforge::VERSION
24
+ end
25
+
26
+ desc "config", "Show current configuration"
27
+ def config
28
+ cfg = build_config
29
+ data = cfg.to_h
30
+ puts "docforge configuration"
31
+ puts "─" * 50
32
+ puts " API key set: #{data[:api_key_set] ? "yes" : "NO — set ANTHROPIC_API_KEY"}"
33
+ puts " Model: #{data[:model]}"
34
+ puts " Output dir: #{data[:output_dir]}"
35
+ puts " Author: #{data[:author]}"
36
+ puts " Input files: prd=#{data[:input_filenames]["prd"]} spec=#{data[:input_filenames]["spec"]} notes=#{data[:input_filenames]["notes"]}"
37
+ puts " System prompt: #{data[:system_prompt_path]}"
38
+ puts " Interview qs: #{data[:interview_question_count]}"
39
+ puts " Loaded configs: #{data[:loaded_config_files].empty? ? "(none — using built-in defaults)" : data[:loaded_config_files].join(", ")}"
40
+ end
41
+
42
+ desc "init", "Write a starter .docforge.yml in the current directory"
43
+ option :force, type: :boolean, default: false, desc: "Overwrite an existing .docforge.yml"
44
+ def init
45
+ target = File.join(Dir.pwd, ".docforge.yml")
46
+ if File.exist?(target) && !options[:force]
47
+ warn "✗ #{target} already exists — pass --force to overwrite."
48
+ exit 1
49
+ end
50
+
51
+ File.write(target, starter_yaml)
52
+ puts "✓ Wrote #{target}"
53
+ puts " Edit it to point at your project's PRD/SPEC filenames and (optionally) a custom prompt."
54
+ end
55
+
56
+ desc "generate FOLDER", "Generate a feature brief .docx from a folder containing PRD + SPEC"
57
+ option :no_interview, type: :boolean, default: false, desc: "Skip the interactive interview"
58
+ option :dry_run, type: :boolean, default: false, desc: "Show what would happen; skip API + render"
59
+ option :model, type: :string, desc: "Override ANTHROPIC_MODEL for this run"
60
+ option :fresh, type: :boolean, default: false, desc: "Ignore .brief-cache.json and re-call the API"
61
+ def generate(folder)
62
+ cfg = build_config
63
+ cfg.instance_variable_set(:@model, options[:model]) if options[:model]
64
+ cfg.validate! unless options[:dry_run] || cache_hit?(folder)
65
+
66
+ puts "→ Reading inputs from #{File.expand_path(folder)}"
67
+ if cfg.loaded_config_files.any?
68
+ puts " · Config: #{cfg.loaded_config_files.join(", ")}"
69
+ end
70
+ inputs = Inputs.read(folder, config: cfg)
71
+ f = cfg.input_filenames
72
+ puts " ✓ #{f["prd"]} (#{inputs.prd.length} chars)"
73
+ puts " ✓ #{f["spec"]} (#{inputs.spec.length} chars)"
74
+ puts " #{inputs.notes ? "✓" : "·"} #{f["notes"]} #{inputs.notes ? "(#{inputs.notes.length} chars)" : "(none)"}"
75
+ puts " #{inputs.assets.any? ? "✓" : "·"} _assets/ (#{inputs.assets.size} files)"
76
+
77
+ cache_path = File.join(inputs.folder, ".brief-cache.json")
78
+ cached = !options[:fresh] && File.exist?(cache_path)
79
+
80
+ if cached
81
+ puts "\n→ Using cached brief (#{cache_path})"
82
+ puts " · pass --fresh to re-call the API"
83
+ cache_data = JSON.parse(File.read(cache_path, encoding: "UTF-8"))
84
+ payload = cache_data["payload"]
85
+ puts " ✓ Cached on #{cache_data["generated_at"]} via #{cache_data["model"]}"
86
+ else
87
+ interview_answers = options[:no_interview] ? {} : run_interview(cfg.interview_questions)
88
+ user_message = Prompt.user_message(inputs: inputs, interview_answers: interview_answers)
89
+
90
+ if options[:dry_run]
91
+ puts "\n→ DRY RUN — would call Anthropic with:"
92
+ puts " Model: #{cfg.model}"
93
+ puts " System prompt length: #{Prompt.system_prompt(config: cfg).length} chars"
94
+ puts " User message length: #{user_message.length} chars"
95
+ puts " Output would land at: #{File.join(cfg.output_dir, inputs.slug)}.docx"
96
+ puts "\nSet ANTHROPIC_API_KEY and re-run without --dry-run to actually generate."
97
+ return
98
+ end
99
+
100
+ spinner = TTY::Spinner.new("[:spinner] Calling Anthropic (#{cfg.model})...", format: :dots)
101
+ spinner.auto_spin
102
+ begin
103
+ client = Client.new(cfg)
104
+ payload = client.generate_brief(
105
+ system_prompt: Prompt.system_prompt(config: cfg),
106
+ user_message: user_message
107
+ )
108
+ spinner.success("(done)")
109
+ rescue StandardError => e
110
+ spinner.error("(failed)")
111
+ raise e
112
+ end
113
+
114
+ File.write(cache_path, JSON.pretty_generate(
115
+ "generated_at" => Time.now.utc.iso8601,
116
+ "model" => cfg.model,
117
+ "payload" => payload
118
+ ))
119
+ puts " ✓ Cached response → #{cache_path}"
120
+ end
121
+
122
+ brief = Brief.new(payload)
123
+ puts " ✓ Brief — #{brief.title.inspect}"
124
+
125
+ puts "\n→ Rendering .docx..."
126
+ path = Renderers::Docx.new(brief: brief, config: cfg, slug: inputs.slug).render
127
+ puts " ✓ Wrote #{path}"
128
+ puts "\nDone. Open it: open \"#{path}\""
129
+ rescue Error => e
130
+ warn "ERROR: #{e.message}"
131
+ exit 1
132
+ end
133
+
134
+ private
135
+
136
+ def build_config
137
+ Config.new(explicit_config_path: options[:config])
138
+ end
139
+
140
+ def cache_hit?(folder)
141
+ return false if options[:fresh]
142
+ File.exist?(File.join(File.expand_path(folder), ".brief-cache.json"))
143
+ end
144
+
145
+ def run_interview(questions)
146
+ prompt = TTY::Prompt.new
147
+ prompt.say "\nInterview (#{questions.size} questions — press Enter to skip any single one)"
148
+ prompt.say "─" * 40
149
+
150
+ answers = {}
151
+ questions.each_with_index do |q, i|
152
+ ans = prompt.ask("[#{i + 1}/#{questions.size}] #{q}")
153
+ answers[q] = ans unless ans.nil? || ans.strip.empty?
154
+ end
155
+ answers
156
+ end
157
+
158
+ def starter_yaml
159
+ <<~YAML
160
+ # docforge project configuration
161
+ # Values here override ~/.docforge.yml. Env vars override these.
162
+
163
+ # Anthropic model (env: ANTHROPIC_MODEL)
164
+ model: claude-sonnet-4-5
165
+
166
+ # Where the generated .docx lands (env: DOCFORGE_OUTPUT_DIR)
167
+ output_dir: ~/Desktop/Portfolio/Features
168
+
169
+ # Byline on the brief (env: DOCFORGE_AUTHOR)
170
+ author: Your Name
171
+
172
+ # Input filenames docforge looks for inside each feature folder
173
+ input_files:
174
+ prd: PRD.md
175
+ spec: SPEC.md
176
+ notes: notes.md
177
+
178
+ # Path to a custom system prompt (relative to this yaml).
179
+ # Comment out to use the gem's bundled prompt.
180
+ # system_prompt: ./prompts/feature_brief.md
181
+
182
+ # Override the interview questions asked before generation.
183
+ # Leave commented out to use the defaults.
184
+ # interview:
185
+ # - "Who is the human user of this feature?"
186
+ # - "What was the world like before this shipped?"
187
+ # - "What was the single hardest design decision, and why?"
188
+ YAML
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "json"
5
+
6
+ module Docforge
7
+ # Thin wrapper around the Anthropic Messages API.
8
+ # Docs: https://docs.anthropic.com/en/api/messages
9
+ #
10
+ # We use *tool-use forcing* to guarantee a structurally valid JSON payload.
11
+ # Free-form text responses occasionally include unescaped quotes inside
12
+ # long string values; tool_use bypasses that entirely because the API
13
+ # validates the tool input against a JSON schema before returning.
14
+ class Client
15
+ include HTTParty
16
+ base_uri "https://api.anthropic.com"
17
+
18
+ API_VERSION = "2023-06-01"
19
+ MAX_TOKENS = 8000
20
+ TOOL_NAME = "emit_brief"
21
+ RAW_DUMP = "/tmp/docforge-last-response.json"
22
+
23
+ def initialize(config)
24
+ @config = config
25
+ end
26
+
27
+ def generate_brief(system_prompt:, user_message:)
28
+ body = {
29
+ model: @config.model,
30
+ max_tokens: MAX_TOKENS,
31
+ system: system_prompt,
32
+ tools: [{
33
+ name: TOOL_NAME,
34
+ description: "Emit the structured feature brief.",
35
+ input_schema: { type: "object", additionalProperties: true }
36
+ }],
37
+ tool_choice: { type: "tool", name: TOOL_NAME },
38
+ messages: [{ role: "user", content: user_message }]
39
+ }
40
+
41
+ response = self.class.post(
42
+ "/v1/messages",
43
+ headers: {
44
+ "x-api-key" => @config.api_key,
45
+ "anthropic-version" => API_VERSION,
46
+ "content-type" => "application/json"
47
+ },
48
+ body: body.to_json,
49
+ timeout: 120
50
+ )
51
+
52
+ raise ApiError, "Anthropic API #{response.code}: #{response.body}" unless response.success?
53
+
54
+ # Dump raw response immediately so tokens are never wasted on a parse fail.
55
+ begin
56
+ File.write(RAW_DUMP, JSON.pretty_generate(response.parsed_response))
57
+ rescue StandardError
58
+ # best-effort
59
+ end
60
+
61
+ extract_tool_input(response.parsed_response)
62
+ end
63
+
64
+ private
65
+
66
+ def extract_tool_input(parsed)
67
+ blocks = Array(parsed["content"])
68
+ tool_block = blocks.find { |b| b["type"] == "tool_use" && b["name"] == TOOL_NAME }
69
+
70
+ if tool_block && tool_block["input"].is_a?(Hash)
71
+ return tool_block["input"]
72
+ end
73
+
74
+ # Fallback: older response shapes or refusal text — try text parsing.
75
+ text = blocks.find { |b| b["type"] == "text" }&.dig("text")
76
+ raise ApiError, "No tool_use block in response. Raw dumped to #{RAW_DUMP}" if text.nil? || text.strip.empty?
77
+
78
+ cleaned = text.strip.sub(/\A```(?:json)?\s*/, "").sub(/\s*```\z/, "")
79
+ JSON.parse(cleaned)
80
+ rescue JSON::ParserError => e
81
+ raise ApiError, "Model did not return valid JSON: #{e.message}\nRaw response saved to #{RAW_DUMP}"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Docforge
6
+ # Layered configuration.
7
+ #
8
+ # Precedence (highest wins):
9
+ # 1. ENV vars
10
+ # 2. Project config (./.docforge.yml, or --config PATH)
11
+ # 3. Global config (~/.docforge.yml)
12
+ # 4. Built-in defaults
13
+ #
14
+ # Env vars:
15
+ # ANTHROPIC_API_KEY — required, your Anthropic API key
16
+ # ANTHROPIC_MODEL — model id (overrides yaml `model`)
17
+ # DOCFORGE_OUTPUT_DIR — where briefs are written
18
+ # DOCFORGE_AUTHOR — byline on the brief
19
+ #
20
+ # YAML schema (all keys optional — see `docforge init` for a template):
21
+ #
22
+ # model: claude-sonnet-4-5
23
+ # output_dir: ~/Desktop/Portfolio/Features
24
+ # author: Your Name
25
+ # input_files:
26
+ # prd: PRD.md
27
+ # spec: SPEC.md
28
+ # notes: notes.md
29
+ # system_prompt: ./prompts/feature_brief.md # relative to this yaml
30
+ # interview:
31
+ # - "Who is the human user of this feature?"
32
+ # - "..."
33
+ class Config
34
+ DEFAULT_MODEL = "claude-sonnet-4-5"
35
+ DEFAULT_OUTPUT_DIR = File.expand_path("~/Desktop/Portfolio/Features")
36
+ DEFAULT_AUTHOR = "You"
37
+ DEFAULT_INPUT_FILES = { "prd" => "PRD.md", "spec" => "SPEC.md", "notes" => "notes.md" }.freeze
38
+ DEFAULT_INTERVIEW = [
39
+ "Who is the human user of this feature? Name them (e.g. 'Mira, a content designer at an EdTech client').",
40
+ "What was the world like *before* this shipped — specifically?",
41
+ "What was the single hardest design decision, and why?",
42
+ "What's the one metric you'd brag about (or a design target if pre-production)?",
43
+ "Is there a moment in the build that taught you something worth conveying?"
44
+ ].freeze
45
+ BUNDLED_SYSTEM_PROMPT_PATH = File.expand_path("../../prompts/system_prompt.md", __dir__)
46
+
47
+ GLOBAL_CONFIG_PATH = File.expand_path("~/.docforge.yml")
48
+ PROJECT_CONFIG_NAME = ".docforge.yml"
49
+
50
+ attr_reader :api_key, :model, :output_dir, :author,
51
+ :input_filenames, :interview_questions,
52
+ :system_prompt_path, :loaded_config_files
53
+
54
+ def initialize(env: ENV, explicit_config_path: nil, cwd: Dir.pwd)
55
+ @loaded_config_files = []
56
+
57
+ global = load_yaml(GLOBAL_CONFIG_PATH)
58
+ project = load_yaml(explicit_config_path || File.join(cwd, PROJECT_CONFIG_NAME))
59
+ merged = global.merge(project)
60
+
61
+ @api_key = env["ANTHROPIC_API_KEY"]
62
+ @model = env["ANTHROPIC_MODEL"] || merged["model"] || DEFAULT_MODEL
63
+ @output_dir = env["DOCFORGE_OUTPUT_DIR"] || merged["output_dir"] || DEFAULT_OUTPUT_DIR
64
+ @author = env["DOCFORGE_AUTHOR"] || merged["author"] || DEFAULT_AUTHOR
65
+ @output_dir = File.expand_path(@output_dir)
66
+
67
+ @input_filenames = DEFAULT_INPUT_FILES.merge(merged["input_files"] || {})
68
+ @interview_questions = Array(merged["interview"]).then { |a| a.empty? ? DEFAULT_INTERVIEW : a }
69
+ @system_prompt_path = resolve_system_prompt_path(merged["system_prompt"], merged["_loaded_from"])
70
+ end
71
+
72
+ def system_prompt_content
73
+ @system_prompt_content ||= File.read(@system_prompt_path)
74
+ end
75
+
76
+ def validate!
77
+ if api_key.nil? || api_key.strip.empty?
78
+ raise ConfigError, <<~MSG
79
+ ANTHROPIC_API_KEY is not set.
80
+ Get one at https://console.anthropic.com/settings/keys and export it:
81
+ export ANTHROPIC_API_KEY=sk-ant-...
82
+ MSG
83
+ end
84
+ unless File.exist?(@system_prompt_path)
85
+ raise ConfigError, "System prompt file not found: #{@system_prompt_path}"
86
+ end
87
+ true
88
+ end
89
+
90
+ def to_h
91
+ {
92
+ api_key_set: !api_key.nil? && !api_key.empty?,
93
+ model: model,
94
+ output_dir: output_dir,
95
+ author: author,
96
+ input_filenames: input_filenames,
97
+ system_prompt_path: system_prompt_path,
98
+ interview_question_count: interview_questions.size,
99
+ loaded_config_files: loaded_config_files
100
+ }
101
+ end
102
+
103
+ private
104
+
105
+ def load_yaml(path)
106
+ return {} unless path && File.exist?(path)
107
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
108
+ @loaded_config_files << path
109
+ data["_loaded_from"] = path
110
+ data
111
+ end
112
+
113
+ # Resolve system_prompt path relative to the yaml file that defined it,
114
+ # so a project-local yaml can point at a project-local prompt.
115
+ def resolve_system_prompt_path(value, yaml_path)
116
+ return BUNDLED_SYSTEM_PROMPT_PATH if value.nil? || value.to_s.strip.empty?
117
+ base = yaml_path ? File.dirname(yaml_path) : Dir.pwd
118
+ File.expand_path(value, base)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docforge
4
+ # Reads a feature folder. Default layout:
5
+ #
6
+ # <folder>/
7
+ # PRD.md (required — name configurable via input_files.prd)
8
+ # SPEC.md (required — name configurable via input_files.spec)
9
+ # notes.md (optional — name configurable via input_files.notes)
10
+ # _assets/ (optional)
11
+ class Inputs
12
+ attr_reader :folder, :prd, :spec, :notes, :assets, :config
13
+
14
+ def self.read(path, config: Config.new)
15
+ new(path, config: config).tap(&:load!)
16
+ end
17
+
18
+ def initialize(path, config: Config.new)
19
+ @folder = File.expand_path(path)
20
+ @config = config
21
+ end
22
+
23
+ def load!
24
+ raise InputError, "Folder not found: #{folder}" unless File.directory?(folder)
25
+
26
+ prd_name = config.input_filenames["prd"]
27
+ spec_name = config.input_filenames["spec"]
28
+ notes_name = config.input_filenames["notes"]
29
+
30
+ prd_path = locate(prd_name)
31
+ spec_path = locate(spec_name)
32
+ raise InputError, "#{prd_name} not found in #{folder}" unless prd_path
33
+ raise InputError, "#{spec_name} not found in #{folder}" unless spec_path
34
+
35
+ @prd = File.read(prd_path)
36
+ @spec = File.read(spec_path)
37
+ notes_p = notes_name ? locate(notes_name) : nil
38
+ @notes = notes_p ? File.read(notes_p) : nil
39
+ @assets = locate_assets
40
+ self
41
+ end
42
+
43
+ def slug
44
+ File.basename(folder).downcase.gsub(/[^a-z0-9-]+/, "-").gsub(/^-|-$/, "")
45
+ end
46
+
47
+ private
48
+
49
+ def locate(filename)
50
+ direct = File.join(folder, filename)
51
+ return direct if File.exist?(direct)
52
+ Dir.glob(File.join(folder, "*", filename)).first
53
+ end
54
+
55
+ def locate_assets
56
+ assets_dir = File.join(folder, "_assets")
57
+ return [] unless File.directory?(assets_dir)
58
+ Dir.glob(File.join(assets_dir, "*")).select { |p| File.file?(p) }.sort
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docforge
4
+ # Builds the user message wrapping the feature's inputs (PRD, SPEC, notes,
5
+ # asset list). The system prompt itself is resolved through Config so it
6
+ # can be overridden per-project.
7
+ module Prompt
8
+ module_function
9
+
10
+ def system_prompt(config:)
11
+ config.system_prompt_content
12
+ end
13
+
14
+ def user_message(inputs:, interview_answers: {})
15
+ <<~MSG
16
+ ## PRD
17
+
18
+ #{inputs.prd}
19
+
20
+ ---
21
+
22
+ ## SPEC
23
+
24
+ #{inputs.spec}
25
+
26
+ #{notes_section(inputs)}
27
+ #{interview_section(interview_answers)}
28
+ #{assets_section(inputs)}
29
+ MSG
30
+ end
31
+
32
+ def notes_section(inputs)
33
+ return "" unless inputs.notes
34
+ <<~SECTION
35
+
36
+ ---
37
+
38
+ ## Author's notes ("what I'm most proud of")
39
+
40
+ #{inputs.notes}
41
+ SECTION
42
+ end
43
+
44
+ def interview_section(answers)
45
+ return "" if answers.nil? || answers.empty?
46
+
47
+ lines = answers.map { |q, a| "**Q: #{q}**\nA: #{a}" }.join("\n\n")
48
+ <<~SECTION
49
+
50
+ ---
51
+
52
+ ## Interview answers
53
+
54
+ #{lines}
55
+ SECTION
56
+ end
57
+
58
+ def assets_section(inputs)
59
+ return "" if inputs.assets.empty?
60
+
61
+ list = inputs.assets.map { |p| "- #{File.basename(p)}" }.join("\n")
62
+ <<~SECTION
63
+
64
+ ---
65
+
66
+ ## Available assets (filenames you may reference)
67
+
68
+ #{list}
69
+ SECTION
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "caracal"
4
+ require "fileutils"
5
+
6
+ module Docforge
7
+ module Renderers
8
+ # Renders a Brief to a styled .docx using caracal.
9
+ #
10
+ # Visual language: "modern tech" — sans-serif throughout, one blue
11
+ # accent, table-heavy, generous whitespace, callout boxes for the
12
+ # pull quote and sharable footer.
13
+ class Docx
14
+ ACCENT = "2563eb" # blue
15
+ NEUTRAL_700 = "374151"
16
+ NEUTRAL_500 = "6b7280"
17
+ NEUTRAL_200 = "e5e7eb"
18
+ CALLOUT_BG = "f8fafc"
19
+
20
+ def initialize(brief:, config:, slug:)
21
+ @brief = brief
22
+ @config = config
23
+ @slug = slug
24
+ end
25
+
26
+ def render
27
+ FileUtils.mkdir_p(@config.output_dir)
28
+ path = File.join(@config.output_dir, "#{@slug}.docx")
29
+ b = @brief
30
+ renderer = self
31
+ Caracal::Document.save(path) do |doc|
32
+ doc.font name: "Calibri"
33
+ doc.page_margins do
34
+ top 1080 # 0.75"
35
+ bottom 1080
36
+ left 1080
37
+ right 1080
38
+ end
39
+
40
+ renderer.send(:render_cover, doc, b)
41
+ doc.page
42
+
43
+ renderer.send(:render_problem, doc, b)
44
+ renderer.send(:render_capabilities, doc, b)
45
+ renderer.send(:render_worked_example, doc, b)
46
+ renderer.send(:render_how_it_works, doc, b)
47
+ renderer.send(:render_capabilities_table, doc, b)
48
+ renderer.send(:render_numbers, doc, b)
49
+ renderer.send(:render_future, doc, b)
50
+ renderer.send(:render_stack, doc, b)
51
+
52
+ doc.page
53
+ renderer.send(:render_sharable, doc, b)
54
+ end
55
+ path
56
+ end
57
+
58
+ private
59
+
60
+ def render_cover(doc, b)
61
+ doc.p
62
+ doc.h1 b.title, color: NEUTRAL_700, size: 44
63
+ doc.p b.value_statement, size: 26, color: ACCENT, italic: false
64
+ doc.hr line: "single", color: ACCENT, size: 8
65
+ doc.p
66
+ doc.p b.pull_quote_italic.to_s, italic: true, size: 22, color: NEUTRAL_500
67
+ doc.p
68
+ render_metadata_table(doc, b.metadata)
69
+ end
70
+
71
+ def render_metadata_table(doc, meta)
72
+ data = []
73
+ data << ["Built for", meta["built_for"].to_s] if meta["built_for"]
74
+ data << ["Shipped", meta["shipped"].to_s] if meta["shipped"]
75
+ data << ["Reading time", meta["reading_time"].to_s] if meta["reading_time"]
76
+ data << ["Tags", Array(meta["tags"]).join(" · ")] if meta["tags"]
77
+
78
+ return if data.empty?
79
+
80
+ neutral_500 = NEUTRAL_500
81
+ neutral_700 = NEUTRAL_700
82
+
83
+ doc.table(data, border_size: 0, width: 9000) do
84
+ cell_style cols[0], color: neutral_500, size: 18, bold: true
85
+ cell_style cols[1], color: neutral_700, size: 18
86
+ end
87
+ end
88
+
89
+ def render_problem(doc, b)
90
+ section_heading(doc, "The problem")
91
+ doc.p b.problem.to_s, size: 22, color: NEUTRAL_700
92
+ end
93
+
94
+ def render_capabilities(doc, b)
95
+ section_heading(doc, "What the feature does")
96
+ b.capabilities.each do |cap|
97
+ doc.p do
98
+ text "#{cap['name']}", bold: true, color: ACCENT, size: 22
99
+ text " — #{cap['description']}", size: 22, color: NEUTRAL_700
100
+ end
101
+ end
102
+ end
103
+
104
+ def render_worked_example(doc, b)
105
+ section_heading(doc, "Worked example")
106
+ we = b.worked_example
107
+ if we["persona_name"]
108
+ doc.p "Meet #{we['persona_name']}.", bold: true, color: NEUTRAL_700, size: 22
109
+ end
110
+ Array(we["story_paragraphs"]).each do |para|
111
+ doc.p para.to_s, size: 22, color: NEUTRAL_700
112
+ end
113
+ end
114
+
115
+ def render_how_it_works(doc, b)
116
+ section_heading(doc, "How it works")
117
+
118
+ data = [["Decision", "Chosen approach", "Why not the alternative"]]
119
+ b.design_decisions.each do |d|
120
+ data << [d["decision"].to_s, d["chosen"].to_s, d["alternative_rejection"].to_s]
121
+ end
122
+
123
+ accent = ACCENT
124
+ neutral_700 = NEUTRAL_700
125
+
126
+ doc.table(data, border_color: NEUTRAL_200, border_size: 4, width: 9000) do
127
+ cell_style rows[0], background: accent, color: "ffffff", bold: true, size: 20
128
+ cell_style rows[1..], size: 18, color: neutral_700
129
+ end
130
+
131
+ doc.p
132
+ sub_heading(doc, "Why this is hard")
133
+ doc.p b.why_hard.to_s, size: 22, color: NEUTRAL_700
134
+ end
135
+
136
+ def render_capabilities_table(doc, b)
137
+ section_heading(doc, "Capabilities this demonstrates")
138
+
139
+ data = [["Capability", "Where it shows up here"]]
140
+ b.capabilities_table.each do |c|
141
+ data << [c["capability"].to_s, c["proof"].to_s]
142
+ end
143
+
144
+ accent = ACCENT
145
+ neutral_700 = NEUTRAL_700
146
+
147
+ doc.table(data, border_color: NEUTRAL_200, border_size: 4, width: 9000) do
148
+ cell_style rows[0], background: accent, color: "ffffff", bold: true, size: 20
149
+ cell_style rows[1..], size: 18, color: neutral_700
150
+ end
151
+ end
152
+
153
+ def render_numbers(doc, b)
154
+ return if b.numbers.empty?
155
+
156
+ section_heading(doc, "Numbers & outcomes")
157
+ b.numbers.each do |n|
158
+ doc.p do
159
+ text "#{n['label']}: ", bold: true, color: NEUTRAL_500, size: 22
160
+ text n["value"].to_s, color: NEUTRAL_700, size: 22
161
+ end
162
+ end
163
+ end
164
+
165
+ def render_future(doc, b)
166
+ return if b.future_extensions.empty?
167
+
168
+ section_heading(doc, "What could be added")
169
+ b.future_extensions.each do |ext|
170
+ doc.ul style: "ListBullet" do
171
+ li ext.to_s, size: 22, color: NEUTRAL_700
172
+ end
173
+ end
174
+ end
175
+
176
+ def render_stack(doc, b)
177
+ sc = b.stack_and_credits
178
+ return if sc.empty?
179
+
180
+ section_heading(doc, "Stack & credits")
181
+ doc.p do
182
+ text "Stack: ", bold: true, color: NEUTRAL_500, size: 20
183
+ text sc["stack"].to_s, color: NEUTRAL_700, size: 20
184
+ end
185
+ if sc["role"]
186
+ doc.p do
187
+ text "Role: ", bold: true, color: NEUTRAL_500, size: 20
188
+ text sc["role"].to_s, color: NEUTRAL_700, size: 20
189
+ end
190
+ end
191
+ if sc["further_reading"]
192
+ doc.p do
193
+ text "Further reading: ", bold: true, color: NEUTRAL_500, size: 20
194
+ text sc["further_reading"].to_s, color: NEUTRAL_700, size: 20
195
+ end
196
+ end
197
+ end
198
+
199
+ def render_sharable(doc, b)
200
+ s = b.sharable
201
+ doc.h2 "Sharable extras", color: ACCENT, size: 28
202
+
203
+ if s["linkedin_summary"]
204
+ sub_heading(doc, "LinkedIn-ready summary")
205
+ doc.p s["linkedin_summary"].to_s, size: 22, color: NEUTRAL_700
206
+ end
207
+
208
+ if s["pull_quote_short"]
209
+ sub_heading(doc, "Pull quote")
210
+ doc.p s["pull_quote_short"].to_s, italic: true, size: 22, color: ACCENT
211
+ end
212
+
213
+ if Array(s["alt_titles"]).any?
214
+ sub_heading(doc, "Alternative title lines")
215
+ s["alt_titles"].each do |t|
216
+ doc.ul style: "ListBullet" do
217
+ li t.to_s, size: 22, color: NEUTRAL_700
218
+ end
219
+ end
220
+ end
221
+
222
+ doc.p
223
+ doc.p "Generated by docforge.", size: 16, color: NEUTRAL_500, italic: true
224
+ end
225
+
226
+ def section_heading(doc, text)
227
+ doc.p
228
+ doc.h2 text, color: NEUTRAL_700, size: 28
229
+ doc.hr line: "single", color: NEUTRAL_200, size: 4
230
+ doc.p
231
+ end
232
+
233
+ def sub_heading(doc, text)
234
+ doc.p text, bold: true, color: NEUTRAL_500, size: 22
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docforge
4
+ VERSION = "0.1.0"
5
+ end
data/lib/docforge.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "docforge/version"
4
+ require_relative "docforge/config"
5
+ require_relative "docforge/inputs"
6
+ require_relative "docforge/prompt"
7
+ require_relative "docforge/client"
8
+ require_relative "docforge/brief"
9
+ require_relative "docforge/renderers/docx"
10
+
11
+ module Docforge
12
+ class Error < StandardError; end
13
+ class ConfigError < Error; end
14
+ class InputError < Error; end
15
+ class ApiError < Error; end
16
+ end
@@ -0,0 +1,101 @@
1
+ # Feature Brief Writer — System Prompt
2
+
3
+ You are a portfolio writer helping the user produce a client-facing brief
4
+ for a feature they built. The brief lives in their personal portfolio. Its
5
+ job is to make a non-technical buyer say "I want this team to build for
6
+ me" within 10 minutes of reading, while a CTO who skims the back half
7
+ walks away thinking "this person is serious."
8
+
9
+ ## Inputs
10
+
11
+ You will receive a PRD describing the user-facing problem and acceptance
12
+ criteria, a SPEC describing the implementation, optionally a short notes
13
+ file ("what I'm most proud of"), and optionally a list of available asset
14
+ filenames (screenshots, diagrams) you can reference.
15
+
16
+ ## Output contract — STRICT
17
+
18
+ Return **valid JSON only** matching this schema. No prose around it, no
19
+ markdown code fences, no explanation. The CLI parses your response with
20
+ `JSON.parse` and any extra text will crash it.
21
+
22
+ ```
23
+ {
24
+ "title": "string — feature name (e.g. 'Branchable Dialog Trees')",
25
+ "value_statement": "string — one sentence in buyer language",
26
+ "pull_quote_italic": "string — opening italic quote, a moment in a named persona's life",
27
+ "metadata": {
28
+ "built_for": "string",
29
+ "shipped": "string (Month YYYY)",
30
+ "reading_time": "string (e.g. '~10 min')",
31
+ "tags": ["string", "string", "..."]
32
+ },
33
+ "problem_paragraph": "string — single paragraph, ~80 words, ends with a hook",
34
+ "capabilities": [
35
+ { "name": "string — bold short label", "description": "string — one sentence" }
36
+ ],
37
+ "worked_example": {
38
+ "persona_name": "string",
39
+ "story_paragraphs": ["string", "string", "string"]
40
+ },
41
+ "design_decisions": [
42
+ {
43
+ "decision": "string — short title",
44
+ "chosen": "string — one-line summary of what was picked",
45
+ "alternative_rejection": "string — one-line rebuttal of the obvious alternative"
46
+ }
47
+ ],
48
+ "why_hard_paragraph": "string — one paragraph on the non-obvious technical problems solved",
49
+ "capabilities_table": [
50
+ { "capability": "string", "proof": "string — one line, where it shows up here" }
51
+ ],
52
+ "numbers": [
53
+ { "label": "string", "value": "string" }
54
+ ],
55
+ "future_extensions": ["string", "string"],
56
+ "stack_and_credits": {
57
+ "stack": "string — comma-separated tech list",
58
+ "role": "string — what the user did",
59
+ "further_reading": "string — links / 'on request'"
60
+ },
61
+ "sharable": {
62
+ "linkedin_summary": "string — ≤80 words",
63
+ "pull_quote_short": "string — one sentence email-signature ready",
64
+ "alt_titles": ["string", "string", "string"]
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## Voice rules
70
+
71
+ - Confident, not boastful. "We replaced X with Y" beats "We pioneered…".
72
+ - Examples over abstractions. The worked example is the highest-leverage
73
+ section.
74
+ - Jargon-light in the first half (problem, capabilities, worked example),
75
+ technically precise in the back half (decisions, why-hard, capabilities
76
+ table).
77
+ - Specific numbers always beat ranges. Never invent a number — if the
78
+ inputs don't contain it, omit it. The `numbers` array can be short.
79
+ - No filler. Cut every sentence that doesn't earn its place.
80
+ - 1,500–2,000 words total across all fields.
81
+
82
+ ## Things to do
83
+
84
+ - Pick a single named persona for the worked example. Invent the name if
85
+ the inputs don't supply one (Mira, Sam, Priya — short, neutral).
86
+ - Pick 3 design decisions. Maximum 5. Never more.
87
+ - Pick 4–6 capabilities for the capabilities table. Map each to a
88
+ concrete proof point from the SPEC.
89
+ - For `pull_quote_italic`, write a one-sentence "moment in time" — a
90
+ timestamped action, an elapsed-time number, something cinematic. Avoid
91
+ generic value statements.
92
+
93
+ ## Things to skip
94
+
95
+ - Don't explain every implementation detail. The SPEC has that.
96
+ - Don't list every file touched.
97
+ - Don't apologize, hedge, or use "essentially" / "basically".
98
+ - Don't compare to competitors.
99
+ - Don't fabricate metrics, dates, or client names.
100
+
101
+ Begin. Return JSON only.
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: docforge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rao Usama Abid
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: caracal
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: httparty
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.22'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.22'
55
+ - !ruby/object:Gem::Dependency
56
+ name: tty-prompt
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.23'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.23'
69
+ - !ruby/object:Gem::Dependency
70
+ name: tty-spinner
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ description: |
98
+ docforge reads a feature folder containing a PRD and a SPEC, calls
99
+ the Anthropic API with a saved feature-brief writer prompt, and emits
100
+ a styled .docx ready to share with non-technical readers (clients,
101
+ portfolios, internal stakeholders).
102
+
103
+ Configurable per-project via .docforge.yml: input filenames, system
104
+ prompt, interview questions, output directory, and model.
105
+ email:
106
+ - usamaroar786@gmail.com
107
+ executables:
108
+ - docforge
109
+ extensions: []
110
+ extra_rdoc_files: []
111
+ files:
112
+ - CHANGELOG.md
113
+ - LICENSE
114
+ - README.md
115
+ - bin/docforge
116
+ - docforge.gemspec
117
+ - lib/docforge.rb
118
+ - lib/docforge/brief.rb
119
+ - lib/docforge/cli.rb
120
+ - lib/docforge/client.rb
121
+ - lib/docforge/config.rb
122
+ - lib/docforge/inputs.rb
123
+ - lib/docforge/prompt.rb
124
+ - lib/docforge/renderers/docx.rb
125
+ - lib/docforge/version.rb
126
+ - prompts/system_prompt.md
127
+ homepage: https://github.com/RaoUsamaAbid/docforge
128
+ licenses:
129
+ - MIT
130
+ metadata:
131
+ homepage_uri: https://github.com/RaoUsamaAbid/docforge
132
+ source_code_uri: https://github.com/RaoUsamaAbid/docforge
133
+ bug_tracker_uri: https://github.com/RaoUsamaAbid/docforge/issues
134
+ changelog_uri: https://github.com/RaoUsamaAbid/docforge/blob/main/CHANGELOG.md
135
+ rubygems_mfa_required: 'true'
136
+ post_install_message:
137
+ rdoc_options: []
138
+ require_paths:
139
+ - lib
140
+ required_ruby_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: 3.2.0
145
+ required_rubygems_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ requirements: []
151
+ rubygems_version: 3.5.22
152
+ signing_key:
153
+ specification_version: 4
154
+ summary: Generate client-facing feature briefs (.docx) from a PRD + SPEC pair using
155
+ Claude.
156
+ test_files: []