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.
- package/CHANGELOG.md +12 -494
- package/README.md +4 -4
- package/docs/cli.md +22 -7
- package/docs/command-map.md +1 -1
- package/docs/engine/commands.md +24 -9
- package/docs/engine/workspace-schema.md +5 -5
- package/engine/commands.go +1 -1
- package/engine/internal/gate/gate.go +9 -0
- package/engine/internal/gate/gate_test.go +110 -0
- package/engine/internal/gate/readiness_binding.go +9 -0
- package/engine/internal/gate/readiness_binding_test.go +2 -2
- package/engine/internal/lib/observe_summary.go +22 -22
- package/engine/internal/lib/open_visual.go +254 -0
- package/engine/internal/lib/open_visual_test.go +280 -0
- package/engine/internal/lib/taskgraph.go +4 -143
- package/engine/internal/lib/taskgraph_test.go +203 -0
- package/engine/internal/lib/visual_outline.go +163 -0
- package/engine/internal/lib/visual_outline_test.go +161 -0
- package/engine/internal/state/taskgraph.go +213 -0
- package/engine/internal/testutil/testutil.go +3 -0
- package/engine/main.go +6 -2
- package/engine/root_routing_test.go +49 -14
- package/engine/testdata/visual/open-visual-smoke.html +675 -0
- package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
- package/engine/tests/adr_0027_readiness_binding_test.go +1 -1
- package/engine/tests/gate_test.go +4 -2
- package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/.claude/skills/rite-define/SKILL.md +10 -1
- package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
- package/package.json +1 -1
- package/scripts/install-lib.sh +1 -1
package/docs/engine/commands.md
CHANGED
|
@@ -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
|
|
19
|
-
| `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
170
|
-
required;
|
|
171
|
-
- `check seal`: final file completeness and open-human-gate checks,
|
|
172
|
-
readiness-binding recheck, then exact
|
|
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
|
|
package/engine/commands.go
CHANGED
|
@@ -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:
|
|
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":
|
|
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
|
|
13
|
-
Phase string
|
|
14
|
-
Status string
|
|
15
|
-
NextAction string
|
|
16
|
-
MissingSections []string
|
|
17
|
-
MissingFiles []string
|
|
18
|
-
PrinciplesPresent bool
|
|
19
|
-
TaskGraph *
|
|
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 = &
|
|
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
|
+
}
|