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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/bin/docforge +7 -0
- data/docforge.gemspec +52 -0
- data/lib/docforge/brief.rb +48 -0
- data/lib/docforge/cli.rb +191 -0
- data/lib/docforge/client.rb +84 -0
- data/lib/docforge/config.rb +121 -0
- data/lib/docforge/inputs.rb +61 -0
- data/lib/docforge/prompt.rb +72 -0
- data/lib/docforge/renderers/docx.rb +238 -0
- data/lib/docforge/version.rb +5 -0
- data/lib/docforge.rb +16 -0
- data/prompts/system_prompt.md +101 -0
- metadata +156 -0
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
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
|
data/lib/docforge/cli.rb
ADDED
|
@@ -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
|
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: []
|