ariadna 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/ariadna.gemspec +0 -1
  3. data/data/agents/ariadna-codebase-mapper.md +34 -722
  4. data/data/agents/ariadna-debugger.md +44 -1139
  5. data/data/agents/ariadna-executor.md +75 -396
  6. data/data/agents/ariadna-planner.md +78 -1215
  7. data/data/agents/ariadna-roadmapper.md +55 -582
  8. data/data/agents/ariadna-verifier.md +60 -702
  9. data/data/ariadna/templates/config.json +8 -33
  10. data/data/ariadna/workflows/debug.md +28 -0
  11. data/data/ariadna/workflows/execute-phase.md +31 -513
  12. data/data/ariadna/workflows/map-codebase.md +20 -319
  13. data/data/ariadna/workflows/new-milestone.md +20 -365
  14. data/data/ariadna/workflows/new-project.md +19 -880
  15. data/data/ariadna/workflows/plan-phase.md +24 -443
  16. data/data/ariadna/workflows/progress.md +20 -376
  17. data/data/ariadna/workflows/quick.md +19 -221
  18. data/data/ariadna/workflows/roadmap-ops.md +28 -0
  19. data/data/ariadna/workflows/verify-work.md +23 -560
  20. data/data/commands/ariadna/add-phase.md +11 -22
  21. data/data/commands/ariadna/debug.md +11 -143
  22. data/data/commands/ariadna/execute-phase.md +12 -30
  23. data/data/commands/ariadna/insert-phase.md +7 -14
  24. data/data/commands/ariadna/map-codebase.md +16 -49
  25. data/data/commands/ariadna/new-milestone.md +12 -25
  26. data/data/commands/ariadna/new-project.md +22 -26
  27. data/data/commands/ariadna/plan-phase.md +13 -22
  28. data/data/commands/ariadna/progress.md +16 -6
  29. data/data/commands/ariadna/quick.md +9 -11
  30. data/data/commands/ariadna/remove-phase.md +9 -12
  31. data/data/commands/ariadna/verify-work.md +14 -19
  32. data/data/skills/rails-backend/API.md +138 -0
  33. data/data/skills/rails-backend/CONTROLLERS.md +154 -0
  34. data/data/skills/rails-backend/JOBS.md +132 -0
  35. data/data/skills/rails-backend/MODELS.md +213 -0
  36. data/data/skills/rails-backend/SKILL.md +169 -0
  37. data/data/skills/rails-frontend/ASSETS.md +154 -0
  38. data/data/skills/rails-frontend/COMPONENTS.md +253 -0
  39. data/data/skills/rails-frontend/SKILL.md +187 -0
  40. data/data/skills/rails-frontend/VIEWS.md +168 -0
  41. data/data/skills/rails-performance/PROFILING.md +106 -0
  42. data/data/skills/rails-performance/SKILL.md +217 -0
  43. data/data/skills/rails-security/AUDIT.md +118 -0
  44. data/data/skills/rails-security/SKILL.md +422 -0
  45. data/data/skills/rails-testing/FIXTURES.md +78 -0
  46. data/data/skills/rails-testing/SKILL.md +160 -0
  47. data/data/skills/rails-testing/SYSTEM-TESTS.md +73 -0
  48. data/lib/ariadna/installer.rb +11 -15
  49. data/lib/ariadna/tools/cli.rb +0 -12
  50. data/lib/ariadna/tools/config_manager.rb +10 -72
  51. data/lib/ariadna/tools/frontmatter.rb +23 -1
  52. data/lib/ariadna/tools/init.rb +201 -401
  53. data/lib/ariadna/tools/model_profiles.rb +6 -14
  54. data/lib/ariadna/tools/phase_manager.rb +1 -10
  55. data/lib/ariadna/tools/state_manager.rb +170 -451
  56. data/lib/ariadna/tools/template_filler.rb +4 -12
  57. data/lib/ariadna/tools/verification.rb +21 -399
  58. data/lib/ariadna/uninstaller.rb +9 -0
  59. data/lib/ariadna/version.rb +1 -1
  60. data/lib/ariadna.rb +1 -0
  61. metadata +20 -91
  62. data/data/agents/ariadna-backend-executor.md +0 -261
  63. data/data/agents/ariadna-frontend-executor.md +0 -259
  64. data/data/agents/ariadna-integration-checker.md +0 -418
  65. data/data/agents/ariadna-phase-researcher.md +0 -469
  66. data/data/agents/ariadna-plan-checker.md +0 -622
  67. data/data/agents/ariadna-project-researcher.md +0 -618
  68. data/data/agents/ariadna-research-synthesizer.md +0 -236
  69. data/data/agents/ariadna-test-executor.md +0 -266
  70. data/data/ariadna/references/checkpoints.md +0 -772
  71. data/data/ariadna/references/continuation-format.md +0 -249
  72. data/data/ariadna/references/decimal-phase-calculation.md +0 -65
  73. data/data/ariadna/references/git-integration.md +0 -248
  74. data/data/ariadna/references/git-planning-commit.md +0 -38
  75. data/data/ariadna/references/model-profile-resolution.md +0 -32
  76. data/data/ariadna/references/model-profiles.md +0 -73
  77. data/data/ariadna/references/phase-argument-parsing.md +0 -61
  78. data/data/ariadna/references/planning-config.md +0 -194
  79. data/data/ariadna/references/questioning.md +0 -153
  80. data/data/ariadna/references/rails-conventions.md +0 -416
  81. data/data/ariadna/references/tdd.md +0 -267
  82. data/data/ariadna/references/ui-brand.md +0 -160
  83. data/data/ariadna/references/verification-patterns.md +0 -853
  84. data/data/ariadna/templates/codebase/architecture.md +0 -481
  85. data/data/ariadna/templates/codebase/concerns.md +0 -380
  86. data/data/ariadna/templates/codebase/conventions.md +0 -434
  87. data/data/ariadna/templates/codebase/integrations.md +0 -328
  88. data/data/ariadna/templates/codebase/stack.md +0 -189
  89. data/data/ariadna/templates/codebase/structure.md +0 -418
  90. data/data/ariadna/templates/codebase/testing.md +0 -606
  91. data/data/ariadna/templates/context.md +0 -283
  92. data/data/ariadna/templates/continue-here.md +0 -78
  93. data/data/ariadna/templates/debug-subagent-prompt.md +0 -91
  94. data/data/ariadna/templates/phase-prompt.md +0 -609
  95. data/data/ariadna/templates/planner-subagent-prompt.md +0 -117
  96. data/data/ariadna/templates/research-project/ARCHITECTURE.md +0 -439
  97. data/data/ariadna/templates/research-project/FEATURES.md +0 -168
  98. data/data/ariadna/templates/research-project/PITFALLS.md +0 -406
  99. data/data/ariadna/templates/research-project/STACK.md +0 -251
  100. data/data/ariadna/templates/research-project/SUMMARY.md +0 -247
  101. data/data/ariadna/templates/state.md +0 -176
  102. data/data/ariadna/templates/summary-complex.md +0 -59
  103. data/data/ariadna/templates/summary-minimal.md +0 -41
  104. data/data/ariadna/templates/summary-standard.md +0 -48
  105. data/data/ariadna/templates/user-setup.md +0 -310
  106. data/data/ariadna/workflows/add-phase.md +0 -111
  107. data/data/ariadna/workflows/add-todo.md +0 -157
  108. data/data/ariadna/workflows/audit-milestone.md +0 -241
  109. data/data/ariadna/workflows/check-todos.md +0 -176
  110. data/data/ariadna/workflows/complete-milestone.md +0 -644
  111. data/data/ariadna/workflows/diagnose-issues.md +0 -219
  112. data/data/ariadna/workflows/discovery-phase.md +0 -289
  113. data/data/ariadna/workflows/discuss-phase.md +0 -408
  114. data/data/ariadna/workflows/execute-plan.md +0 -448
  115. data/data/ariadna/workflows/help.md +0 -470
  116. data/data/ariadna/workflows/insert-phase.md +0 -129
  117. data/data/ariadna/workflows/list-phase-assumptions.md +0 -178
  118. data/data/ariadna/workflows/pause-work.md +0 -122
  119. data/data/ariadna/workflows/plan-milestone-gaps.md +0 -256
  120. data/data/ariadna/workflows/remove-phase.md +0 -154
  121. data/data/ariadna/workflows/research-phase.md +0 -74
  122. data/data/ariadna/workflows/resume-project.md +0 -306
  123. data/data/ariadna/workflows/set-profile.md +0 -80
  124. data/data/ariadna/workflows/settings.md +0 -145
  125. data/data/ariadna/workflows/transition.md +0 -493
  126. data/data/ariadna/workflows/update.md +0 -212
  127. data/data/ariadna/workflows/verify-phase.md +0 -226
  128. data/data/commands/ariadna/add-todo.md +0 -42
  129. data/data/commands/ariadna/audit-milestone.md +0 -42
  130. data/data/commands/ariadna/check-todos.md +0 -41
  131. data/data/commands/ariadna/complete-milestone.md +0 -136
  132. data/data/commands/ariadna/discuss-phase.md +0 -86
  133. data/data/commands/ariadna/help.md +0 -22
  134. data/data/commands/ariadna/list-phase-assumptions.md +0 -50
  135. data/data/commands/ariadna/pause-work.md +0 -35
  136. data/data/commands/ariadna/plan-milestone-gaps.md +0 -40
  137. data/data/commands/ariadna/reapply-patches.md +0 -110
  138. data/data/commands/ariadna/research-phase.md +0 -187
  139. data/data/commands/ariadna/resume-work.md +0 -40
  140. data/data/commands/ariadna/set-profile.md +0 -34
  141. data/data/commands/ariadna/settings.md +0 -36
  142. data/data/commands/ariadna/update.md +0 -37
  143. data/data/guides/backend.md +0 -3069
  144. data/data/guides/frontend.md +0 -1479
  145. data/data/guides/performance.md +0 -1193
  146. data/data/guides/security.md +0 -1522
  147. data/data/guides/style-guide.md +0 -1091
  148. data/data/guides/testing.md +0 -504
  149. data/data/templates.md +0 -94
@@ -1,465 +1,199 @@
1
1
  require "json"
2
2
  require_relative "output"
3
- require_relative "config_manager"
4
3
  require_relative "frontmatter"
5
4
 
6
5
  module Ariadna
7
6
  module Tools
8
- module StateManager
9
- def self.dispatch(argv, raw: false)
10
- subcommand = argv.shift
7
+ # Memory-directory-based state management using Memory Tool verbs.
8
+ module StateManager # rubocop:disable Metrics/ModuleLength
9
+ MEMORY_DIR = ".ariadna_planning/memory".freeze
10
+
11
+ def self.dispatch(args, raw: false) # rubocop:disable Metrics/CyclomaticComplexity
12
+ subcommand = args.shift
11
13
  case subcommand
12
- when "load"
13
- load_state(raw: raw)
14
- when "get"
15
- get(argv.first, raw: raw)
16
- when "update"
17
- update(argv[0], argv[1], raw: raw)
18
- when "patch"
19
- patches = parse_patches(argv)
20
- patch(patches, raw: raw)
21
- when "advance-plan"
22
- advance_plan(raw: raw)
23
- when "record-metric"
24
- options = parse_named_args(argv, %w[phase plan duration tasks files])
25
- record_metric(options, raw: raw)
26
- when "update-progress"
27
- update_progress(raw: raw)
28
- when "add-decision"
29
- options = parse_named_args(argv, %w[phase summary rationale])
30
- add_decision(options, raw: raw)
31
- when "add-blocker"
32
- text = extract_flag(argv, "--text") || argv.first
33
- add_blocker(text, raw: raw)
34
- when "resolve-blocker"
35
- text = extract_flag(argv, "--text") || argv.first
36
- resolve_blocker(text, raw: raw)
37
- when "record-session"
38
- options = parse_named_args(argv, %w[stopped-at resume-file])
39
- record_session(options, raw: raw)
40
- else
41
- Output.error("Unknown state subcommand: #{subcommand}")
42
- end
14
+ when "list" then list_memory(args, raw: raw)
15
+ when "view" then view(args, raw: raw)
16
+ when "create" then create(args, raw: raw)
17
+ when "update" then update(args, raw: raw)
18
+ when "insert" then insert(args, raw: raw)
19
+ when "delete" then delete_file(args, raw: raw)
20
+ when "add-decision" then add_decision(args, raw: raw)
21
+ when "add-blocker" then add_blocker(args, raw: raw)
22
+ when "resolve-blocker" then resolve_blocker(args, raw: raw)
23
+ when "record-metric" then record_metric(args, raw: raw)
24
+ when "record-session" then record_session(args, raw: raw)
25
+ when "history-digest" then history_digest(args, raw: raw)
26
+ else Output.error("Unknown state subcommand: #{subcommand}")
27
+ end
28
+ end
29
+
30
+ def self.list_memory(_args, raw: false)
31
+ dir = memory_path
32
+ FileUtils.mkdir_p(dir)
33
+ files = Dir.children(dir).sort.map do |name|
34
+ { name: name, size: File.stat(File.join(dir, name)).size }
35
+ end
36
+ Output.json({ files: files }, raw: raw)
37
+ end
38
+
39
+ def self.view(args, raw: false)
40
+ filename = args.shift
41
+ Output.error("filename required") unless filename
42
+ path = safe_path!(filename)
43
+ Output.error("File not found: #{filename}") unless File.exist?(path)
44
+ content = File.read(path)
45
+ Output.json({ file: filename, content: content, lines: content.lines.count }, raw: raw)
46
+ end
47
+
48
+ def self.create(args, raw: false)
49
+ filename = args.shift
50
+ content = args.shift || ""
51
+ Output.error("filename required") unless filename
52
+ path = safe_path!(filename)
53
+ Output.error("File already exists: #{filename}") if File.exist?(path)
54
+ FileUtils.mkdir_p(File.dirname(path))
55
+ File.write(path, content)
56
+ Output.json({ created: true, file: filename }, raw: raw)
57
+ end
58
+
59
+ def self.update(args, raw: false)
60
+ filename, old_str, new_str = args.shift(3)
61
+ Output.error("filename, old_str, and new_str required") unless filename && old_str && new_str
62
+ path = safe_path!(filename)
63
+ Output.error("File not found: #{filename}") unless File.exist?(path)
64
+ content = File.read(path)
65
+ count = content.scan(old_str).length
66
+ Output.error("old_str not found in #{filename}") if count.zero?
67
+ Output.error("old_str is ambiguous (#{count} occurrences) in #{filename}") if count > 1
68
+ File.write(path, content.sub(old_str, new_str))
69
+ Output.json({ updated: true, file: filename }, raw: raw)
70
+ end
71
+
72
+ def self.insert(args, raw: false)
73
+ filename = args.shift
74
+ line_num = args.shift&.to_i
75
+ text = args.shift
76
+ Output.error("filename, line_number, and text required") unless filename && line_num && text
77
+ path = safe_path!(filename)
78
+ Output.error("File not found: #{filename}") unless File.exist?(path)
79
+ lines = File.readlines(path)
80
+ lines.insert([line_num - 1, 0].max, "#{text}\n")
81
+ File.write(path, lines.join)
82
+ Output.json({ inserted: true, file: filename, at_line: line_num }, raw: raw)
83
+ end
84
+
85
+ def self.delete_file(args, raw: false)
86
+ filename = args.shift
87
+ Output.error("filename required") unless filename
88
+ path = safe_path!(filename)
89
+ Output.error("File not found: #{filename}") unless File.exist?(path)
90
+ File.delete(path)
91
+ Output.json({ deleted: true, file: filename }, raw: raw)
92
+ end
93
+
94
+ def self.add_decision(args, raw: false)
95
+ opts = parse_named_args(args, %w[phase summary rationale])
96
+ summary = opts["summary"]
97
+ Output.error("--summary required") unless summary
98
+ phase = opts["phase"] || "?"
99
+ suffix = opts["rationale"] ? " -- #{opts['rationale']}" : ""
100
+ entry = "- [Phase #{phase}]: #{summary}#{suffix}"
101
+ append_to_memory_file("decisions.md", "# Decisions", entry)
102
+ Output.json({ added: true, decision: entry }, raw: raw)
103
+ end
104
+
105
+ def self.add_blocker(args, raw: false)
106
+ text = extract_flag(args, "--text") || args.first
107
+ Output.error("--text required") unless text
108
+ append_to_memory_file("blockers.md", "# Blockers", "- #{text}")
109
+ Output.json({ added: true, blocker: text }, raw: raw)
110
+ end
111
+
112
+ def self.resolve_blocker(args, raw: false)
113
+ text = extract_flag(args, "--text") || args.first
114
+ Output.error("--text required") unless text
115
+ path = File.join(memory_path, "blockers.md")
116
+ Output.error("blockers.md not found") unless File.exist?(path)
117
+ lines = File.read(path).split("\n")
118
+ filtered = lines.reject { |l| l.start_with?("- ") && l.downcase.include?(text.downcase) }
119
+ File.write(path, "#{filtered.join("\n")}\n")
120
+ Output.json({ resolved: true, blocker: text }, raw: raw)
121
+ end
122
+
123
+ def self.record_metric(args, raw: false)
124
+ opts = parse_named_args(args, %w[phase plan duration tasks files])
125
+ phase, plan, duration = opts.values_at("phase", "plan", "duration")
126
+ Output.error("--phase, --plan, and --duration required") unless phase && plan && duration
127
+ tasks = opts["tasks"] || "-"
128
+ files = opts["files"] || "-"
129
+ entry = "| Phase #{phase} P#{plan} | #{duration} | #{tasks} tasks | #{files} files |"
130
+ append_to_memory_file("metrics.md", "# Metrics", entry)
131
+ Output.json({ recorded: true, phase: phase, plan: plan, duration: duration }, raw: raw)
132
+ end
133
+
134
+ def self.record_session(args, raw: false)
135
+ opts = parse_named_args(args, %w[stopped-at resume-file])
136
+ now = Time.now.utc.iso8601
137
+ lines = ["# Session", "", "**Last session:** #{now}"]
138
+ lines << "**Stopped at:** #{opts['stopped-at']}" if opts["stopped-at"]
139
+ lines << "**Resume file:** #{opts['resume-file']}" if opts["resume-file"]
140
+ path = File.join(memory_path, "session.md")
141
+ FileUtils.mkdir_p(File.dirname(path))
142
+ File.write(path, "#{lines.join("\n")}\n")
143
+ Output.json({ recorded: true, timestamp: now }, raw: raw)
43
144
  end
44
145
 
45
- def self.history_digest(_argv, raw: false)
46
- cwd = Dir.pwd
47
- phases_dir = File.join(cwd, ".ariadna_planning", "phases")
48
- digest = { phases: {}, decisions: [], tech_stack: [] }
146
+ def self.history_digest(_args, raw: false)
147
+ phases_dir = File.join(Dir.pwd, ".ariadna_planning", "phases")
148
+ lines = ["# History Digest", ""]
149
+ collect_phase_summaries(phases_dir, lines) if File.directory?(phases_dir)
150
+ path = File.join(memory_path, "history.md")
151
+ FileUtils.mkdir_p(File.dirname(path))
152
+ File.write(path, "#{lines.join("\n")}\n")
153
+ Output.json({ created: true, file: "history.md" }, raw: raw)
154
+ end
49
155
 
50
- unless File.directory?(phases_dir)
51
- Output.json(digest, raw: raw)
52
- return
53
- end
156
+ # --- Private helpers ---
54
157
 
55
- tech_stack_set = []
158
+ def self.collect_phase_summaries(phases_dir, lines) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
56
159
  Dir.children(phases_dir).sort.each do |dir|
57
160
  dir_path = File.join(phases_dir, dir)
58
161
  next unless File.directory?(dir_path)
59
162
 
60
- summaries = Dir.children(dir_path).select { |f| f.end_with?("-SUMMARY.md", "SUMMARY.md") }
61
- summaries.each do |summary|
62
- content = File.read(File.join(dir_path, summary))
63
- fm = Frontmatter.extract(content)
64
-
65
- phase_num = fm["phase"] || dir.split("-").first
66
- digest[:phases][phase_num] ||= { name: dir.split("-")[1..].join(" ") || "Unknown", provides: [], affects: [], patterns: [] }
67
-
68
- dep_graph = fm["dependency-graph"] || {}
69
- (dep_graph["provides"] || fm["provides"] || []).each { |p| digest[:phases][phase_num][:provides] << p }
70
- (dep_graph["affects"] || []).each { |a| digest[:phases][phase_num][:affects] << a }
71
- (fm["patterns-established"] || []).each { |p| digest[:phases][phase_num][:patterns] << p }
72
- (fm["key-decisions"] || []).each { |d| digest[:decisions] << { phase: phase_num, decision: d } }
73
-
74
- tech = fm["tech-stack"]
75
- (tech["added"] || []).each { |t| tech_stack_set << (t.is_a?(String) ? t : t["name"]) } if tech.is_a?(Hash)
163
+ lines << "## #{dir}"
164
+ Dir.children(dir_path).select { |f| f.end_with?("SUMMARY.md") }.sort.each do |s|
165
+ fm = Frontmatter.extract(File.read(File.join(dir_path, s)))
166
+ lines << "- **#{s}**: #{(fm['provides'] || []).join(', ')}"
167
+ (fm["key-decisions"] || []).each { |d| lines << " - Decision: #{d}" }
76
168
  rescue StandardError
77
169
  next
78
170
  end
171
+ lines << ""
79
172
  end
80
-
81
- digest[:phases].each_value do |p|
82
- p[:provides].uniq!
83
- p[:affects].uniq!
84
- p[:patterns].uniq!
85
- end
86
- digest[:tech_stack] = tech_stack_set.uniq
87
-
88
- Output.json(digest, raw: raw)
89
- end
90
-
91
- def self.summary_extract(argv, raw: false)
92
- path = argv.shift
93
- Output.error("path required") unless path
94
- fields_idx = argv.index("--fields")
95
- fields = fields_idx ? argv[fields_idx + 1]&.split(",") : nil
96
-
97
- cwd = Dir.pwd
98
- full_path = File.expand_path(path, cwd)
99
- content = File.read(full_path)
100
- fm = Frontmatter.extract(content)
101
-
102
- result = fields ? fm.slice(*fields) : fm
103
- Output.json(result, raw: raw)
104
- rescue Errno::ENOENT
105
- Output.error("File not found: #{path}")
106
- end
107
-
108
- def self.snapshot(_argv, raw: false)
109
- cwd = Dir.pwd
110
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
111
- Output.error("STATE.md not found") unless File.exist?(state_path)
112
-
113
- content = File.read(state_path)
114
- fields = {}
115
- content.scan(/\*\*(.+?):\*\*\s*(.+)/) { |k, v| fields[k.strip] = v.strip }
116
-
117
- Output.json(fields, raw: raw)
118
- end
119
-
120
- # --- Core state operations ---
121
-
122
- def self.load_state(raw: false)
123
- cwd = Dir.pwd
124
- config = ConfigManager.load_config(cwd)
125
- planning_dir = File.join(cwd, ".ariadna_planning")
126
-
127
- state_raw = begin
128
- File.read(File.join(planning_dir, "STATE.md"))
129
- rescue Errno::ENOENT
130
- ""
131
- end
132
-
133
- result = {
134
- config: config,
135
- state_raw: state_raw,
136
- state_exists: !state_raw.empty?,
137
- roadmap_exists: File.exist?(File.join(planning_dir, "ROADMAP.md")),
138
- config_exists: File.exist?(File.join(planning_dir, "config.json"))
139
- }
140
-
141
- if raw
142
- lines = config.map { |k, v| "#{k}=#{v}" }
143
- lines << "config_exists=#{result[:config_exists]}"
144
- lines << "roadmap_exists=#{result[:roadmap_exists]}"
145
- lines << "state_exists=#{result[:state_exists]}"
146
- $stdout.write(lines.join("\n"))
147
- exit 0
148
- end
149
-
150
- Output.json(result)
151
- end
152
-
153
- def self.get(section, raw: false)
154
- cwd = Dir.pwd
155
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
156
- content = File.read(state_path)
157
-
158
- unless section
159
- Output.json({ content: content }, raw: raw, raw_value: content)
160
- return
161
- end
162
-
163
- escaped = Regexp.escape(section)
164
-
165
- # Check for **field:** value
166
- if (match = content.match(/\*\*#{escaped}:\*\*\s*(.*)/i))
167
- Output.json({ section => match[1].strip }, raw: raw, raw_value: match[1].strip)
168
- return
169
- end
170
-
171
- # Check for ## Section
172
- if (match = content.match(/##\s*#{escaped}\s*\n([\s\S]*?)(?=\n##|$)/i))
173
- Output.json({ section => match[1].strip }, raw: raw, raw_value: match[1].strip)
174
- return
175
- end
176
-
177
- Output.json({ error: "Section or field \"#{section}\" not found" }, raw: raw, raw_value: "")
178
- rescue Errno::ENOENT
179
- Output.error("STATE.md not found")
180
173
  end
181
174
 
182
- def self.update(field, value, raw: false)
183
- Output.error("field and value required for state update") unless field && value
184
-
185
- cwd = Dir.pwd
186
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
187
- content = File.read(state_path)
188
-
189
- new_content = replace_field(content, field, value)
190
- if new_content
191
- File.write(state_path, new_content)
192
- Output.json({ updated: true })
193
- else
194
- Output.json({ updated: false, reason: "Field \"#{field}\" not found in STATE.md" })
195
- end
196
- rescue Errno::ENOENT
197
- Output.json({ updated: false, reason: "STATE.md not found" })
175
+ def self.memory_path
176
+ File.join(Dir.pwd, MEMORY_DIR)
198
177
  end
199
178
 
200
- def self.patch(patches, raw: false)
201
- cwd = Dir.pwd
202
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
203
- content = File.read(state_path)
204
-
205
- results = { updated: [], failed: [] }
206
- patches.each do |field, value|
207
- new_content = replace_field(content, field, value)
208
- if new_content
209
- content = new_content
210
- results[:updated] << field
211
- else
212
- results[:failed] << field
213
- end
214
- end
215
-
216
- File.write(state_path, content) unless results[:updated].empty?
217
- Output.json(results, raw: raw, raw_value: results[:updated].empty? ? "false" : "true")
218
- rescue Errno::ENOENT
219
- Output.error("STATE.md not found")
179
+ def self.safe_path!(filename)
180
+ dir = memory_path
181
+ resolved = File.expand_path(filename, dir)
182
+ Output.error("Path outside memory directory: #{filename}") unless resolved.start_with?(File.expand_path(dir))
183
+ resolved
220
184
  end
221
185
 
222
- def self.advance_plan(raw: false)
223
- cwd = Dir.pwd
224
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
225
- unless File.exist?(state_path)
226
- Output.json({ error: "STATE.md not found" }, raw: raw)
227
- return
228
- end
229
-
230
- content = File.read(state_path)
231
- current_plan = extract_field(content, "Current Plan")&.to_i
232
- total_plans = extract_field(content, "Total Plans in Phase")&.to_i
233
- today = Time.now.utc.strftime("%Y-%m-%d")
234
-
235
- unless current_plan && total_plans
236
- Output.json({ error: "Cannot parse Current Plan or Total Plans in Phase from STATE.md" }, raw: raw)
237
- return
238
- end
239
-
240
- if current_plan >= total_plans
241
- content = replace_field(content, "Status", "Phase complete — ready for verification") || content
242
- content = replace_field(content, "Last Activity", today) || content
243
- File.write(state_path, content)
244
- Output.json({ advanced: false, reason: "last_plan", current_plan: current_plan, total_plans: total_plans }, raw: raw, raw_value: "false")
186
+ def self.append_to_memory_file(filename, header, entry)
187
+ dir = memory_path
188
+ FileUtils.mkdir_p(dir)
189
+ path = File.join(dir, filename)
190
+ if File.exist?(path)
191
+ File.write(path, "#{File.read(path).rstrip}\n#{entry}\n")
245
192
  else
246
- new_plan = current_plan + 1
247
- content = replace_field(content, "Current Plan", new_plan.to_s) || content
248
- content = replace_field(content, "Status", "Ready to execute") || content
249
- content = replace_field(content, "Last Activity", today) || content
250
- File.write(state_path, content)
251
- Output.json({ advanced: true, previous_plan: current_plan, current_plan: new_plan, total_plans: total_plans }, raw: raw, raw_value: "true")
193
+ File.write(path, "#{header}\n\n#{entry}\n")
252
194
  end
253
195
  end
254
196
 
255
- def self.record_metric(options, raw: false)
256
- cwd = Dir.pwd
257
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
258
- unless File.exist?(state_path)
259
- Output.json({ error: "STATE.md not found" }, raw: raw)
260
- return
261
- end
262
-
263
- phase = options["phase"]
264
- plan = options["plan"]
265
- duration = options["duration"]
266
- Output.error("phase, plan, and duration required") unless phase && plan && duration
267
-
268
- content = File.read(state_path)
269
- new_row = "| Phase #{phase} P#{plan} | #{duration} | #{options['tasks'] || '-'} tasks | #{options['files'] || '-'} files |"
270
-
271
- pattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|\z)/i
272
- if (match = content.match(pattern))
273
- body = match[2].rstrip
274
- body = body.strip.empty? || body.include?("None yet") ? new_row : "#{body}\n#{new_row}"
275
- content = content.sub(pattern, "#{match[1]}#{body}\n")
276
- File.write(state_path, content)
277
- Output.json({ recorded: true, phase: phase, plan: plan, duration: duration }, raw: raw, raw_value: "true")
278
- else
279
- Output.json({ recorded: false, reason: "Performance Metrics section not found" }, raw: raw, raw_value: "false")
280
- end
281
- end
282
-
283
- def self.update_progress(raw: false)
284
- cwd = Dir.pwd
285
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
286
- unless File.exist?(state_path)
287
- Output.json({ error: "STATE.md not found" }, raw: raw)
288
- return
289
- end
290
-
291
- content = File.read(state_path)
292
- phases_dir = File.join(cwd, ".ariadna_planning", "phases")
293
- total_plans = 0
294
- total_summaries = 0
295
-
296
- if File.directory?(phases_dir)
297
- Dir.children(phases_dir).each do |dir|
298
- dir_path = File.join(phases_dir, dir)
299
- next unless File.directory?(dir_path)
300
-
301
- files = Dir.children(dir_path)
302
- total_plans += files.count { |f| f.match?(/-PLAN\.md$/i) }
303
- total_summaries += files.count { |f| f.match?(/-SUMMARY\.md$/i) }
304
- end
305
- end
306
-
307
- percent = total_plans > 0 ? (total_summaries.to_f / total_plans * 100).round : 0
308
- bar_width = 10
309
- filled = (percent.to_f / 100 * bar_width).round
310
- bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
311
- progress_str = "[#{bar}] #{percent}%"
312
-
313
- if content.match?(/\*\*Progress:\*\*/i) || content.match?(/^Progress:/i)
314
- content = content.sub(/(Progress:\s*).*/i, "\\1#{progress_str}")
315
- File.write(state_path, content)
316
- Output.json({ updated: true, percent: percent, completed: total_summaries, total: total_plans, bar: progress_str }, raw: raw, raw_value: progress_str)
317
- else
318
- Output.json({ updated: false, reason: "Progress field not found" }, raw: raw, raw_value: "false")
319
- end
320
- end
321
-
322
- def self.add_decision(options, raw: false)
323
- cwd = Dir.pwd
324
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
325
- unless File.exist?(state_path)
326
- Output.json({ error: "STATE.md not found" }, raw: raw)
327
- return
328
- end
329
-
330
- summary = options["summary"]
331
- Output.error("summary required") unless summary
332
-
333
- phase = options["phase"] || "?"
334
- rationale = options["rationale"]
335
- entry = "- [Phase #{phase}]: #{summary}#{rationale ? " — #{rationale}" : ''}"
336
-
337
- content = File.read(state_path)
338
- pattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|\z)/i
339
-
340
- if (match = content.match(pattern))
341
- body = match[2].gsub(/None yet\.?\s*\n?/i, "").gsub(/No decisions yet\.?\s*\n?/i, "")
342
- body = "#{body.rstrip}\n#{entry}\n"
343
- content = content.sub(pattern, "#{match[1]}#{body}")
344
- File.write(state_path, content)
345
- Output.json({ added: true, decision: entry }, raw: raw, raw_value: "true")
346
- else
347
- Output.json({ added: false, reason: "Decisions section not found" }, raw: raw, raw_value: "false")
348
- end
349
- end
350
-
351
- def self.add_blocker(text, raw: false)
352
- cwd = Dir.pwd
353
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
354
- unless File.exist?(state_path)
355
- Output.json({ error: "STATE.md not found" }, raw: raw)
356
- return
357
- end
358
- Output.error("text required") unless text
359
-
360
- content = File.read(state_path)
361
- entry = "- #{text}"
362
- pattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|\z)/i
363
-
364
- if (match = content.match(pattern))
365
- body = match[2].gsub(/None\.?\s*\n?/i, "").gsub(/None yet\.?\s*\n?/i, "")
366
- body = "#{body.rstrip}\n#{entry}\n"
367
- content = content.sub(pattern, "#{match[1]}#{body}")
368
- File.write(state_path, content)
369
- Output.json({ added: true, blocker: text }, raw: raw, raw_value: "true")
370
- else
371
- Output.json({ added: false, reason: "Blockers section not found" }, raw: raw, raw_value: "false")
372
- end
373
- end
374
-
375
- def self.resolve_blocker(text, raw: false)
376
- cwd = Dir.pwd
377
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
378
- unless File.exist?(state_path)
379
- Output.json({ error: "STATE.md not found" }, raw: raw)
380
- return
381
- end
382
- Output.error("text required") unless text
383
-
384
- content = File.read(state_path)
385
- pattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|\z)/i
386
-
387
- if (match = content.match(pattern))
388
- lines = match[2].split("\n")
389
- filtered = lines.reject { |line| line.start_with?("- ") && line.downcase.include?(text.downcase) }
390
- new_body = filtered.join("\n")
391
- new_body = "None\n" if new_body.strip.empty? || !new_body.include?("- ")
392
- content = content.sub(pattern, "#{match[1]}#{new_body}")
393
- File.write(state_path, content)
394
- Output.json({ resolved: true, blocker: text }, raw: raw, raw_value: "true")
395
- else
396
- Output.json({ resolved: false, reason: "Blockers section not found" }, raw: raw, raw_value: "false")
397
- end
398
- end
399
-
400
- def self.record_session(options, raw: false)
401
- cwd = Dir.pwd
402
- state_path = File.join(cwd, ".ariadna_planning", "STATE.md")
403
- unless File.exist?(state_path)
404
- Output.json({ error: "STATE.md not found" }, raw: raw)
405
- return
406
- end
407
-
408
- content = File.read(state_path)
409
- now = Time.now.utc.iso8601
410
- updated = []
411
-
412
- new_content = replace_field(content, "Last session", now)
413
- if new_content
414
- content = new_content
415
- updated << "Last session"
416
- end
417
-
418
- if options["stopped-at"]
419
- %w[Stopped At Stopped at].each do |field|
420
- new_content = replace_field(content, field, options["stopped-at"])
421
- if new_content
422
- content = new_content
423
- updated << "Stopped At"
424
- break
425
- end
426
- end
427
- end
428
-
429
- resume = options["resume-file"] || "None"
430
- %w[Resume\ File Resume\ file].each do |field|
431
- new_content = replace_field(content, field, resume)
432
- if new_content
433
- content = new_content
434
- updated << "Resume File"
435
- break
436
- end
437
- end
438
-
439
- if updated.any?
440
- File.write(state_path, content)
441
- Output.json({ recorded: true, updated: updated }, raw: raw, raw_value: "true")
442
- else
443
- Output.json({ recorded: false, reason: "No session fields found" }, raw: raw, raw_value: "false")
444
- end
445
- end
446
-
447
- # --- Private helpers ---
448
-
449
- def self.extract_field(content, field_name)
450
- pattern = /\*\*#{Regexp.escape(field_name)}:\*\*\s*(.+)/i
451
- match = content.match(pattern)
452
- match ? match[1].strip : nil
453
- end
454
-
455
- def self.replace_field(content, field_name, new_value)
456
- escaped = Regexp.escape(field_name)
457
- pattern = /(\*\*#{escaped}:\*\*\s*)(.*)/i
458
- return nil unless content.match?(pattern)
459
-
460
- content.sub(pattern, "\\1#{new_value}")
461
- end
462
-
463
197
  def self.extract_flag(argv, flag)
464
198
  idx = argv.index(flag)
465
199
  return nil unless idx
@@ -467,32 +201,17 @@ module Ariadna
467
201
  argv[idx + 1]
468
202
  end
469
203
 
470
- def self.parse_patches(argv)
471
- patches = {}
472
- i = 0
473
- while i < argv.length
474
- if argv[i].start_with?("--")
475
- key = argv[i].sub(/\A--/, "")
476
- patches[key] = argv[i + 1]
477
- i += 2
478
- else
479
- i += 1
480
- end
481
- end
482
- patches
483
- end
484
-
485
204
  def self.parse_named_args(argv, known_keys)
486
205
  result = {}
487
206
  known_keys.each do |key|
488
- flag = "--#{key}"
489
- idx = argv.index(flag)
207
+ idx = argv.index("--#{key}")
490
208
  result[key] = argv[idx + 1] if idx
491
209
  end
492
210
  result
493
211
  end
494
212
 
495
- private_class_method :extract_field, :replace_field, :extract_flag, :parse_patches, :parse_named_args
213
+ private_class_method :memory_path, :safe_path!, :append_to_memory_file,
214
+ :extract_flag, :parse_named_args, :collect_phase_summaries
496
215
  end
497
216
  end
498
217
  end