devrites 4.3.0 → 4.4.1

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 (79) hide show
  1. package/CHANGELOG.md +12 -494
  2. package/README.md +4 -4
  3. package/docs/cli.md +22 -7
  4. package/docs/command-map.md +1 -1
  5. package/docs/engine/commands.md +24 -9
  6. package/docs/engine/workspace-schema.md +5 -5
  7. package/engine/commands.go +1 -1
  8. package/engine/internal/gate/gate.go +9 -0
  9. package/engine/internal/gate/gate_test.go +110 -0
  10. package/engine/internal/gate/readiness_binding.go +9 -0
  11. package/engine/internal/gate/readiness_binding_test.go +2 -2
  12. package/engine/internal/lib/observe_summary.go +22 -22
  13. package/engine/internal/lib/open_visual.go +254 -0
  14. package/engine/internal/lib/open_visual_test.go +280 -0
  15. package/engine/internal/lib/taskgraph.go +4 -143
  16. package/engine/internal/lib/taskgraph_test.go +203 -0
  17. package/engine/internal/lib/visual_outline.go +163 -0
  18. package/engine/internal/lib/visual_outline_test.go +161 -0
  19. package/engine/internal/state/taskgraph.go +213 -0
  20. package/engine/internal/testutil/testutil.go +3 -0
  21. package/engine/main.go +6 -2
  22. package/engine/root_routing_test.go +49 -14
  23. package/engine/testdata/visual/open-visual-smoke.html +675 -0
  24. package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
  25. package/engine/tests/adr_0027_readiness_binding_test.go +1 -1
  26. package/engine/tests/gate_test.go +4 -2
  27. package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
  28. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  29. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  30. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  31. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  32. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  33. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  34. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  35. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  36. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  37. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  38. package/pack/.claude/skills/rite-define/SKILL.md +10 -1
  39. package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
  40. package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
  41. package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +4 -1
  42. package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
  43. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
  44. package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
  45. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  46. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  47. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  48. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  49. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  50. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  51. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  52. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  53. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  54. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  55. package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
  56. package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
  57. package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
  58. package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +4 -1
  59. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
  60. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
  61. package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
  62. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  63. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  64. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  65. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  66. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  67. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  68. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  69. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  70. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  71. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  72. package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
  73. package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
  74. package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
  75. package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +4 -1
  76. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
  77. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
  78. package/package.json +1 -1
  79. package/scripts/install-lib.sh +1 -1
@@ -7,21 +7,22 @@ workflow strategy.
7
7
  ## Complete operational command inventory
8
8
 
9
9
  | Command | Deterministic responsibility |
10
- |---|---|
10
+ | --- | --- |
11
11
  | `install [flags]` | Install manifest-owned host artifacts and the optional shared binary. |
12
12
  | `update [flags]` | Refresh an existing managed installation. |
13
13
  | `uninstall [flags]` | Remove managed artifacts while preserving runtime workspace state. |
14
14
  | `check candidate <slug>` | Validate the strict manifest and compute the content-bound project-candidate identity. |
15
- | `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, and the current stable Build-input binding when applicable. |
15
+ | `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, the `tasks.md` slice graph when that artifact is required, and the current stable Build-input binding when applicable. |
16
16
  | `check readiness --emit-binding <slug>` | Render the exact stable Build-input binding for Vet to record after review. |
17
- | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the stable Build-input binding, and exact candidate bindings. |
18
- | `check path-disjoint [--root <dir>] [<json-file>|-]` | Verify slice path sets are pairwise disjoint. |
19
- | `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and unknown dependencies. |
17
+ | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the `tasks.md` slice graph, the stable Build-input binding, and exact candidate bindings. |
18
+ | `check path-disjoint [--root <dir>] [<json-file> | -]` | Verify slice path sets are pairwise disjoint. |
19
+ | `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles, unknown deps, malformed tokens, duplicate IDs, missing `Dependencies`, and `depends_on` mismatch. |
20
20
  | `check skill-trust <path>` | Scan one skill/agent Markdown file for structural trust violations. |
21
- | `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. |
21
+ | `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. `task_graph.ok` is true iff `task_graph.problems` is empty; `problems` lists cycle, unknown-dep, malformed-token, duplicate-id, and missing-`Dependencies` blockers. |
22
22
  | `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
23
23
  | `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
24
24
  | `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
25
+ | `open-visual <path-or-name> [--slug <slug>] [--no-open]` | Resolve a local visual HTML file, optionally open it in the OS browser, warn if the sibling outline is missing or inventory ids are absent from HTML, and print agent path tips. No network. |
25
26
  | `version` | Print the engine version. |
26
27
 
27
28
  `help`, `-h`, and `--help` print this operational inventory. `version` and
@@ -35,8 +36,9 @@ not to the engine command namespace.
35
36
  The candidate gate validates and hashes path/state/type/mode/content identity;
36
37
  it does not infer scope from Git. The readiness gate checks target-Phase
37
38
  structure and applies open-question blocking only when that target is Clarify
38
- or later, plus the exact stable Build-input binding after Vet. The seal gate
39
- always targets Phase `seal`, repeats that binding, and checks exact candidate
39
+ or later, plus the exact `tasks.md` slice graph when `tasks.md` is required,
40
+ plus the exact stable Build-input binding after Vet. The seal gate
41
+ always targets Phase `seal`, repeats that graph and binding, and checks exact candidate
40
42
  bindings in evidence, optional browser evidence, review, and seal. None judges
41
43
  the meaning of `CLEAR`/`READY` prose,
42
44
  parses reviewer narratives, infers acceptance coverage, counts assertions,
@@ -86,6 +88,19 @@ Each invocation accepts at most 4,096 entries, 64 MiB total captured input, and
86
88
  4,096 findings. Findings never include matched bytes, excerpts, or value hashes.
87
89
  Input, limit, and output errors exit `2`; HIGH findings exit `3`.
88
90
 
91
+ ## Open visual
92
+
93
+ `open-visual` resolves `<path-or-name>` to a local `.html` file under the
94
+ active/`DEVRITES_WORKSPACE`/`--slug` workspace `visual/` directory, or via an
95
+ absolute/relative path. Missing sibling `.outline.md` warns on stderr but does
96
+ not hard-fail. When the outline exists, the engine compares `## ID inventory`
97
+ ids to HTML `id="..."` attributes and warns (non-fatal) for inventory ids
98
+ missing from HTML; HTML-only decorative ids are ignored. Unless `--no-open`,
99
+ the engine starts the OS opener (`open`, `xdg-open`, or Windows `start`) for
100
+ the local file only — never a network fetch. Stdout prints the absolute HTML
101
+ path, outline path tip, playbook index hint, and an `ids=ok` / `ids=mismatch`
102
+ summary when an inventory is present.
103
+
89
104
  ## Output and exit contracts
90
105
 
91
106
  `check candidate` passes with exactly:
@@ -121,7 +136,7 @@ artifact is classified as `absent`, `empty`, `malformed`, `unsafe`,
121
136
  The closed diagnostic codes and recoveries are:
122
137
 
123
138
  | Code | Exact Gate recovery | Exact standalone readiness-binding payload |
124
- |---|---|---|
139
+ | --- | --- | --- |
125
140
  | `malformed_markdown` | `next: repair <logical-path>: replace invalid Markdown with valid Markdown; required artifacts need substantive content` | `readiness input <logical-path> is malformed (malformed_markdown); replace invalid Markdown with valid Markdown` |
126
141
  | `parent_symlink` | `next: repair <logical-path>: replace the symlinked parent with a real directory` | `readiness input <logical-path> is unsafe (parent_symlink); replace the symlinked parent with a real directory` |
127
142
  | `final_symlink` | `next: repair <logical-path>: replace the symlink with a regular file` | `readiness input <logical-path> is unsafe (final_symlink); replace the symlink with a regular file` |
@@ -166,11 +166,11 @@ The engine provides only:
166
166
  - `check candidate`: strict candidate-manifest validation and content-bound
167
167
  identity;
168
168
  - `check readiness`: phase-relative file completeness, open-human-gate check,
169
- and the stable vetted Build-input binding whenever `eng-review.md` is
170
- required;
171
- - `check seal`: final file completeness and open-human-gate checks, then the
172
- readiness-binding recheck, then exact candidate bindings after that aggregate
173
- gate passes;
169
+ the `tasks.md` slice graph when that artifact is required, and the stable
170
+ vetted Build-input binding whenever `eng-review.md` is required;
171
+ - `check seal`: final file completeness and open-human-gate checks, the
172
+ `tasks.md` slice graph, then the readiness-binding recheck, then exact
173
+ candidate bindings after that aggregate gate passes;
174
174
  - atomic `state resolve` answer/drop/batch and transactional `state close`;
175
175
  - secret scanning, version reporting, and local install lifecycle primitives.
176
176
 
@@ -26,7 +26,7 @@ const (
26
26
  func rootModeFor(command string, args []string) rootMode {
27
27
  subcommand := firstRootOperand(args)
28
28
  switch command {
29
- case "secret-scan":
29
+ case "secret-scan", "open-visual":
30
30
  return rootLenient
31
31
  case "state":
32
32
  switch subcommand {
@@ -94,6 +94,15 @@ func checkObservation(kind Kind, observation *state.WorkspaceObservation) (*Resu
94
94
  blocked = true
95
95
  }
96
96
  }
97
+ if len(missingFiles) == 0 && phaseRequiresTasks(policy) {
98
+ if fact, ok := observation.Fact("tasks.md"); ok && fact.State() == state.ArtifactPresent {
99
+ graph := state.ParseTaskGraph(fact.Bytes())
100
+ for _, problem := range graph.Problems {
101
+ stateProblems = append(stateProblems, "task-graph: "+problem)
102
+ blocked = true
103
+ }
104
+ }
105
+ }
97
106
  if len(missingFiles) == 0 && phaseRequiresReadinessBinding(policy) {
98
107
  expected, bindingErr := verifyReadinessBinding(observation)
99
108
  if bindingErr != nil {
@@ -12,6 +12,114 @@ import (
12
12
  "github.com/devrites/devrites/internal/testutil"
13
13
  )
14
14
 
15
+ func TestCheckBlocksCyclicTaskGraphWhenTasksAreRequired(t *testing.T) {
16
+ root := t.TempDir()
17
+ workspace := writeReadinessFixture(t, root, "cyclic", "build")
18
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
19
+
20
+ ## SLICE-001 A
21
+ Dependencies: SLICE-002
22
+
23
+ ## SLICE-002 B
24
+ Dependencies: SLICE-001
25
+ `)
26
+ binding := mustReadinessBinding(t, root, "cyclic")
27
+ testutil.AppendFile(t, filepath.Join(workspace, "eng-review.md"), "\n"+binding+"\n")
28
+
29
+ res, err := Check(Readiness, root, "cyclic")
30
+ if err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ if !res.Blocked || res.ReasonID != reason.GateReadinessMissing {
34
+ t.Fatalf("blocked=%v reason=%q, want blocked missing", res.Blocked, res.ReasonID)
35
+ }
36
+ joined := strings.Join(res.StateProblems, "\n")
37
+ if !strings.Contains(joined, "task-graph: dependency cycle:") {
38
+ t.Fatalf("StateProblems=%q", joined)
39
+ }
40
+ if !strings.Contains(res.Render(), "result: blocked (state invariant)") {
41
+ t.Fatalf("Render()=\n%s", res.Render())
42
+ }
43
+ }
44
+
45
+ func TestCheckBlocksCyclicTaskGraphAtSeal(t *testing.T) {
46
+ root := t.TempDir()
47
+ writeCompleteGateFeature(t, root, "cyclic-seal", state.PhaseSeal, state.PhaseSeal, "none\n")
48
+ workspace := filepath.Join(root, "work", "cyclic-seal")
49
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
50
+
51
+ ## SLICE-001 A
52
+ Dependencies: SLICE-002
53
+
54
+ ## SLICE-002 B
55
+ Dependencies: SLICE-001
56
+ `)
57
+ binding := mustReadinessBinding(t, root, "cyclic-seal")
58
+ testutil.AppendFile(t, filepath.Join(workspace, "eng-review.md"), "\n"+binding+"\n")
59
+
60
+ res, err := Check(Seal, root, "cyclic-seal")
61
+ if err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ if !res.Blocked || res.ReasonID != reason.GateSealMissing {
65
+ t.Fatalf("blocked=%v reason=%q, want blocked %s", res.Blocked, res.ReasonID, reason.GateSealMissing)
66
+ }
67
+ joined := strings.Join(res.StateProblems, "\n")
68
+ if !strings.Contains(joined, "task-graph: dependency cycle:") {
69
+ t.Fatalf("StateProblems=%q", joined)
70
+ }
71
+ if !strings.Contains(res.Render(), "result: blocked (state invariant)") {
72
+ t.Fatalf("Render()=\n%s", res.Render())
73
+ }
74
+ }
75
+
76
+ func TestCheckBlocksMalformedTaskGraphInsteadOfDroppingTokens(t *testing.T) {
77
+ root := t.TempDir()
78
+ workspace := writeReadinessFixture(t, root, "malformed", "define")
79
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
80
+
81
+ ## SLICE-001 Ready
82
+ Dependencies: none
83
+
84
+ ## SLICE-002 Next
85
+ Dependencies: SLICE-001 and later
86
+ `)
87
+
88
+ res, err := Check(Readiness, root, "malformed")
89
+ if err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ if !res.Blocked {
93
+ t.Fatal("expected malformed dependency to block readiness")
94
+ }
95
+ joined := strings.Join(res.StateProblems, "\n")
96
+ if !strings.Contains(joined, `malformed dependency "and"`) || !strings.Contains(joined, `malformed dependency "later"`) {
97
+ t.Fatalf("StateProblems=%q", joined)
98
+ }
99
+ }
100
+
101
+ func TestCheckBlocksMissingDependenciesInsteadOfTreatingSliceAsIndependent(t *testing.T) {
102
+ root := t.TempDir()
103
+ workspace := writeReadinessFixture(t, root, "nodeps", "define")
104
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
105
+
106
+ ## SLICE-001 Ready
107
+ Goal: looks complete without an ordering field
108
+ `)
109
+
110
+ res, err := Check(Readiness, root, "nodeps")
111
+ if err != nil {
112
+ t.Fatal(err)
113
+ }
114
+ if !res.Blocked {
115
+ t.Fatal("expected missing Dependencies to block readiness")
116
+ }
117
+ joined := strings.Join(res.StateProblems, "\n")
118
+ if !strings.Contains(joined, "SLICE-001 is missing Dependencies") {
119
+ t.Fatalf("StateProblems=%q", joined)
120
+ }
121
+ }
122
+
15
123
  func TestCheckAndRenderReadiness(t *testing.T) {
16
124
  root := t.TempDir()
17
125
  writeFeature(t, root, "alpha", map[string]string{
@@ -505,6 +613,8 @@ func writeCompleteGateFeature(t *testing.T, root, slug string, current, required
505
613
  case "questions.md":
506
614
  questionsRequired = true
507
615
  content = questions
616
+ case "tasks.md":
617
+ content = testutil.CanonicalTasksMarkdown
508
618
  }
509
619
  testutil.WriteFile(t, filepath.Join(root, "work", slug, name), content)
510
620
  }
@@ -148,6 +148,15 @@ func readinessDiagnosticError(diagnostic state.ArtifactDiagnostic) error {
148
148
  return errors.New(prefix + repair)
149
149
  }
150
150
 
151
+ func phaseRequiresTasks(policy state.PhasePolicy) bool {
152
+ for _, artifact := range policy.RequiredArtifacts {
153
+ if artifact == "tasks.md" {
154
+ return true
155
+ }
156
+ }
157
+ return false
158
+ }
159
+
151
160
  func phaseRequiresReadinessBinding(policy state.PhasePolicy) bool {
152
161
  for _, artifact := range policy.RequiredArtifacts {
153
162
  if artifact == "eng-review.md" {
@@ -91,7 +91,7 @@ func TestReadinessBindingBindsOnlyStableBuildInputs(t *testing.T) {
91
91
  func TestReadinessBindingGoldenDigest(t *testing.T) {
92
92
  root := t.TempDir()
93
93
  writeReadinessFixture(t, root, "golden", "build")
94
- const want = "Readiness inputs SHA-256: d84b9050bd8db742c6a379a966bd7457cde04a69d6811130817729776d976ebe"
94
+ const want = "Readiness inputs SHA-256: c4a073e85373f5fd9f9302c61b6772e766e4fa2a3da2ccc77bad23756c9f412d"
95
95
  if got := mustReadinessBinding(t, root, "golden"); got != want {
96
96
  t.Fatalf("ReadinessBinding()=%q, want %q", got, want)
97
97
  }
@@ -374,7 +374,7 @@ func writeReadinessFixture(t *testing.T, root, slug, phase string) string {
374
374
  "decision-coverage.md": "# Decision coverage\n\nCLEAR\n",
375
375
  "architecture.md": "# Architecture\n\nReady.\n",
376
376
  "plan.md": "# Plan\n\nReady.\n",
377
- "tasks.md": "# Tasks\n\nReady.\n",
377
+ "tasks.md": testutil.CanonicalTasksMarkdown,
378
378
  "traceability.md": "# Traceability\n\nReady.\n",
379
379
  "eng-review.md": "# Engineering review\n\nREADY\n",
380
380
  "test-plan.md": "# Test plan\n\nReady.\n",
@@ -7,21 +7,25 @@ import (
7
7
  "github.com/devrites/devrites/internal/state"
8
8
  )
9
9
 
10
+ // ObserveTaskGraph is the slice-graph subset of ObserveSummary.
11
+ type ObserveTaskGraph struct {
12
+ SliceCount int `json:"slice_count"`
13
+ Cycle []string `json:"cycle,omitempty"`
14
+ Unknown []string `json:"unknown_dependencies,omitempty"`
15
+ Problems []string `json:"problems,omitempty"`
16
+ OK bool `json:"ok"`
17
+ }
18
+
10
19
  // ObserveSummary is a sanitized, machine-readable workspace snapshot.
11
20
  type ObserveSummary struct {
12
- Slug string `json:"slug"`
13
- Phase string `json:"phase,omitempty"`
14
- Status string `json:"status,omitempty"`
15
- NextAction string `json:"next_action,omitempty"`
16
- MissingSections []string `json:"missing_sections,omitempty"`
17
- MissingFiles []string `json:"missing_files,omitempty"`
18
- PrinciplesPresent bool `json:"principles_present"`
19
- TaskGraph *struct {
20
- SliceCount int `json:"slice_count"`
21
- Cycle []string `json:"cycle,omitempty"`
22
- Unknown []string `json:"unknown_dependencies,omitempty"`
23
- OK bool `json:"ok"`
24
- } `json:"task_graph,omitempty"`
21
+ Slug string `json:"slug"`
22
+ Phase string `json:"phase,omitempty"`
23
+ Status string `json:"status,omitempty"`
24
+ NextAction string `json:"next_action,omitempty"`
25
+ MissingSections []string `json:"missing_sections,omitempty"`
26
+ MissingFiles []string `json:"missing_files,omitempty"`
27
+ PrinciplesPresent bool `json:"principles_present"`
28
+ TaskGraph *ObserveTaskGraph `json:"task_graph,omitempty"`
25
29
  }
26
30
 
27
31
  // ObserveSummaryFor builds a summary for one feature slug.
@@ -40,16 +44,12 @@ func ObserveSummaryFor(root, slug string) (ObserveSummary, error) {
40
44
  MissingSections: missingSectionNames(report.Missing),
41
45
  }
42
46
 
43
- if graph, graphErr := CheckTaskGraph(root, slug); graphErr == nil && len(graph.Slices) > 0 {
44
- summary.TaskGraph = &struct {
45
- SliceCount int `json:"slice_count"`
46
- Cycle []string `json:"cycle,omitempty"`
47
- Unknown []string `json:"unknown_dependencies,omitempty"`
48
- OK bool `json:"ok"`
49
- }{
47
+ if graph, graphErr := CheckTaskGraph(root, slug); graphErr == nil && (len(graph.Slices) > 0 || len(graph.Problems) > 0) {
48
+ summary.TaskGraph = &ObserveTaskGraph{
50
49
  SliceCount: len(graph.Slices),
51
- Cycle: graph.Cycle,
52
- Unknown: graph.Unknown,
50
+ Cycle: append([]string(nil), graph.Cycle...),
51
+ Unknown: append([]string(nil), graph.Unknown...),
52
+ Problems: append([]string(nil), graph.Problems...),
53
53
  OK: len(graph.Problems) == 0,
54
54
  }
55
55
  }
@@ -0,0 +1,254 @@
1
+ package lib
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+
12
+ "github.com/devrites/devrites/internal/devritespaths"
13
+ "github.com/devrites/devrites/internal/safepath"
14
+ )
15
+
16
+ const openVisualPlaybookHint = "pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md"
17
+
18
+ // openVisualOpener launches a local file in the OS default browser.
19
+ // Tests replace this to assert --no-open and avoid spawning a browser.
20
+ var openVisualOpener = openLocalFile
21
+
22
+ // OpenVisual resolves a workspace visual HTML file, optionally opens it in the
23
+ // OS browser, warns when the sibling outline is missing or inventory ids are
24
+ // absent from HTML, and prints an agent tip. It never performs network I/O.
25
+ //
26
+ // Usage: open-visual <path-or-name> [--slug <slug>] [--no-open]
27
+ func OpenVisual(root string, args []string, stdout, stderr io.Writer) int {
28
+ operand, slug, noOpen, err := parseOpenVisualArgs(args)
29
+ if err != nil {
30
+ fmt.Fprintf(stderr, "open-visual: %v\n", err)
31
+ fmt.Fprintln(stderr, "usage: devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]")
32
+ return 2
33
+ }
34
+
35
+ htmlPath, nameMode, err := resolveOpenVisualHTML(root, slug, operand)
36
+ if err != nil {
37
+ fmt.Fprintf(stderr, "open-visual: %v\n", err)
38
+ return 2
39
+ }
40
+
41
+ info, err := os.Stat(htmlPath)
42
+ if err != nil {
43
+ fmt.Fprintf(stderr, "open-visual: cannot open %s: %v\n", htmlPath, err)
44
+ return 2
45
+ }
46
+ if info.IsDir() {
47
+ fmt.Fprintf(stderr, "open-visual: %s is a directory, not an HTML file\n", htmlPath)
48
+ return 2
49
+ }
50
+ if resolved, err := filepath.EvalSymlinks(htmlPath); err == nil {
51
+ htmlPath = resolved
52
+ }
53
+ if !strings.EqualFold(filepath.Ext(htmlPath), ".html") {
54
+ fmt.Fprintf(stderr, "open-visual: require a .html file, got %s\n", htmlPath)
55
+ return 2
56
+ }
57
+ if nameMode {
58
+ feature, err := resolveOpenVisualFeatureDir(root, slug)
59
+ if err != nil {
60
+ fmt.Fprintf(stderr, "open-visual: %v\n", err)
61
+ return 2
62
+ }
63
+ visualDir := filepath.Join(feature, "visual")
64
+ if !safepath.WithinResolved(htmlPath, visualDir) {
65
+ fmt.Fprintf(stderr, "open-visual: refused: resolved path escapes workspace visual/\n")
66
+ return 2
67
+ }
68
+ }
69
+
70
+ outlinePath := strings.TrimSuffix(htmlPath, filepath.Ext(htmlPath)) + ".outline.md"
71
+ outlineMissing := false
72
+ if st, err := os.Stat(outlinePath); err != nil || st.IsDir() {
73
+ outlineMissing = true
74
+ fmt.Fprintf(stderr, "open-visual: warning: missing outline companion %s\n", outlinePath)
75
+ }
76
+
77
+ var idReport VisualIDConsistency
78
+ idsChecked := false
79
+ if !outlineMissing {
80
+ htmlBody, herr := os.ReadFile(htmlPath)
81
+ outlineBody, oerr := os.ReadFile(outlinePath)
82
+ switch {
83
+ case herr != nil:
84
+ fmt.Fprintf(stderr, "open-visual: warning: cannot read HTML for id check: %v\n", herr)
85
+ case oerr != nil:
86
+ fmt.Fprintf(stderr, "open-visual: warning: cannot read outline for id check: %v\n", oerr)
87
+ default:
88
+ idReport = CheckVisualIDConsistency(string(htmlBody), string(outlineBody))
89
+ idsChecked = true
90
+ if len(idReport.MissingInHTML) > 0 {
91
+ fmt.Fprintf(stderr, "open-visual: warning: %d outline inventory id(s) missing from HTML: %s\n",
92
+ len(idReport.MissingInHTML), strings.Join(idReport.MissingInHTML, ", "))
93
+ }
94
+ }
95
+ }
96
+
97
+ // Print tips before OS open so agents still get paths if the opener fails.
98
+ fmt.Fprintf(stdout, "open-visual: html=%s\n", htmlPath)
99
+ if outlineMissing {
100
+ fmt.Fprintf(stdout, "open-visual: outline=(missing) %s\n", outlinePath)
101
+ } else {
102
+ fmt.Fprintf(stdout, "open-visual: outline=%s\n", outlinePath)
103
+ }
104
+ fmt.Fprintf(stdout, "open-visual: playbooks=%s\n", openVisualPlaybookHint)
105
+ if idsChecked {
106
+ switch {
107
+ case len(idReport.MissingInHTML) > 0:
108
+ fmt.Fprintf(stdout, "open-visual: ids=mismatch missing=%d inventory=%d\n",
109
+ len(idReport.MissingInHTML), len(idReport.Inventory))
110
+ case len(idReport.Inventory) > 0:
111
+ fmt.Fprintf(stdout, "open-visual: ids=ok (%d inventory)\n", len(idReport.Inventory))
112
+ }
113
+ }
114
+
115
+ if !noOpen {
116
+ if err := openVisualOpener(htmlPath); err != nil {
117
+ fmt.Fprintf(stderr, "open-visual: warning: failed to open browser: %v\n", err)
118
+ // Tips already printed; HTML is local and resolved — warn-and-continue.
119
+ }
120
+ }
121
+ return 0
122
+ }
123
+
124
+ func parseOpenVisualArgs(args []string) (operand, slug string, noOpen bool, err error) {
125
+ for i := 0; i < len(args); i++ {
126
+ arg := args[i]
127
+ switch {
128
+ case arg == "--no-open":
129
+ noOpen = true
130
+ case arg == "--slug":
131
+ if i+1 >= len(args) {
132
+ return "", "", false, fmt.Errorf("--slug requires a value")
133
+ }
134
+ i++
135
+ slug = strings.TrimSpace(args[i])
136
+ if slug == "" {
137
+ return "", "", false, fmt.Errorf("--slug requires a value")
138
+ }
139
+ case strings.HasPrefix(arg, "--slug="):
140
+ slug = strings.TrimSpace(strings.TrimPrefix(arg, "--slug="))
141
+ if slug == "" {
142
+ return "", "", false, fmt.Errorf("--slug requires a value")
143
+ }
144
+ case strings.HasPrefix(arg, "-"):
145
+ return "", "", false, fmt.Errorf("unknown flag %q", arg)
146
+ default:
147
+ if operand != "" {
148
+ return "", "", false, fmt.Errorf("unexpected argument %q", arg)
149
+ }
150
+ operand = arg
151
+ }
152
+ }
153
+ if strings.TrimSpace(operand) == "" {
154
+ return "", "", false, fmt.Errorf("path or visual name required")
155
+ }
156
+ return operand, slug, noOpen, nil
157
+ }
158
+
159
+ // resolveOpenVisualHTML returns the HTML path and whether the operand was a
160
+ // workspace visual name (nameMode). Absolute/relative path operands open any
161
+ // local .html; name operands resolve under the feature visual/ tree.
162
+ func resolveOpenVisualHTML(root, slug, operand string) (string, bool, error) {
163
+ if isOpenVisualPathOperand(operand) {
164
+ abs, err := filepath.Abs(operand)
165
+ if err != nil {
166
+ retErr := fmt.Errorf("resolve path: %w", err)
167
+ return "", false, retErr
168
+ }
169
+ cleaned := filepath.Clean(abs)
170
+ return cleaned, false, nil
171
+ }
172
+
173
+ name := operand
174
+ if !strings.EqualFold(filepath.Ext(name), ".html") {
175
+ name += ".html"
176
+ }
177
+ if filepath.Base(name) != name {
178
+ retErr := fmt.Errorf("visual name must not contain path separators")
179
+ return "", true, retErr
180
+ }
181
+
182
+ feature, err := resolveOpenVisualFeatureDir(root, slug)
183
+ if err != nil {
184
+ return "", true, err
185
+ }
186
+ htmlPath := filepath.Join(feature, "visual", name)
187
+ return htmlPath, true, nil
188
+ }
189
+
190
+ // isOpenVisualPathOperand reports whether operand is a filesystem path rather
191
+ // than a workspace visual name. Absolute paths and operands with separators are
192
+ // paths. Leading "./" or "../" (and bare "." / "..") are relative paths; a
193
+ // leading-dot basename alone (e.g. ".draft") is still a visual name.
194
+ func isOpenVisualPathOperand(operand string) bool {
195
+ if filepath.IsAbs(operand) {
196
+ return true
197
+ }
198
+ if operand == "." || operand == ".." {
199
+ return true
200
+ }
201
+ if strings.HasPrefix(operand, "./") || strings.HasPrefix(operand, "../") {
202
+ return true
203
+ }
204
+ if strings.HasPrefix(operand, `.\`) || strings.HasPrefix(operand, `..\`) {
205
+ return true
206
+ }
207
+ return strings.ContainsAny(operand, `/\`)
208
+ }
209
+
210
+ func resolveOpenVisualFeatureDir(root, slug string) (string, error) {
211
+ if root == "" {
212
+ retErr := fmt.Errorf("DevRites root required to resolve a visual name")
213
+ return "", retErr
214
+ }
215
+ if slug == "" {
216
+ active, err := devritespaths.ActiveSlug(root)
217
+ if err != nil {
218
+ return "", err
219
+ }
220
+ if active == "" {
221
+ retErr := fmt.Errorf("no slug: pass --slug or set ACTIVE / DEVRITES_WORKSPACE")
222
+ return "", retErr
223
+ }
224
+ slug = active
225
+ }
226
+ dir, err := devritespaths.ExistingFeatureDirChecked(root, slug)
227
+ if err != nil {
228
+ if os.IsNotExist(err) {
229
+ retErr := fmt.Errorf("no workspace for slug %q", slug)
230
+ return "", retErr
231
+ }
232
+ return "", err
233
+ }
234
+ return dir, nil
235
+ }
236
+
237
+ func openLocalFile(path string) error {
238
+ var cmd *exec.Cmd
239
+ switch runtime.GOOS {
240
+ case "darwin":
241
+ // argv only — no shell.
242
+ cmd = exec.Command("open", path)
243
+ case "windows":
244
+ // Avoid cmd.exe /c shell composition; FileProtocolHandler takes one path argv.
245
+ cmd = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", path)
246
+ default:
247
+ // argv only — no shell.
248
+ cmd = exec.Command("xdg-open", path)
249
+ }
250
+ if err := cmd.Start(); err != nil {
251
+ return fmt.Errorf("open local file: %w", err)
252
+ }
253
+ return nil
254
+ }