diff --git a/README.md b/README.md index ba8e6ae..62fdb53 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ bin/gsp.exe .\bin\gsp.exe index --root examples\lottery --out .gsp\index.json .\bin\gsp.exe flatten page.lottery.main --root examples\lottery --depth -1 --out .gsp\flattened.json .\bin\gsp.exe pack page.lottery.main --root examples\lottery --depth -1 --budget 12000 --out .gsp\context-pack.json +.\bin\gsp.exe pack page.lottery.main --root examples\lottery --for implement --stage implement --format md --out .gsp\context-pack.md +.\bin\gsp.exe impact feedback.positive --root examples\lottery --format md --out .gsp\impact.md +.\bin\gsp.exe impact feedback.positive --root examples\lottery --format canvas --out .gsp\impact.canvas +.\bin\gsp.exe message validate messages\implement-page.gspmsg --root examples\lottery --out .gsp\message-report.json .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format mermaid --out .gsp\graph.mmd .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format md --out .gsp\graph.md .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format canvas --out .gsp\graph.canvas @@ -63,6 +67,10 @@ bin/gsp.exe .gsp/index.json .gsp/flattened.json .gsp/context-pack.json +.gsp/context-pack.md +.gsp/impact.md +.gsp/impact.canvas +.gsp/message-report.json .gsp/graph.mmd .gsp/graph.md .gsp/graph.canvas @@ -77,8 +85,10 @@ bin/gsp.exe | `specs/versions/0.1/README.md` | GSP 0.1 语言说明。 | | `specs/versions/0.1/gsp.schema.json` | GSP 0.1 核心字段 schema。 | | `specs/versions/0.1/gsp.manifest.schema.json` | GSP 0.1 工程 manifest schema。 | +| `specs/versions/0.1/gsp.message.schema.json` | GSP 0.1 agent 通信消息 schema。 | | `specs/versions/0.1/commands.md` | GSP 0.1 命令规范。 | | `specs/versions/0.1/ai-usage.md` | GSP 0.1 AI 使用规则。 | +| `specs/versions/0.1/message.md` | GSP Message 说明。 | | `toolkit/README.md` | GSP Toolkit 命令与实现说明。 | ## GSP 工程结构 @@ -144,6 +154,9 @@ gsp completion install powershell gsp validate gsp index gsp flatten +gsp pack --for implement --format md +gsp impact --format md +gsp message validate message.gspmsg gsp graph gsp stage-check --stage implement ``` diff --git a/examples/lottery/README.md b/examples/lottery/README.md index 5b51a95..c4fc592 100644 --- a/examples/lottery/README.md +++ b/examples/lottery/README.md @@ -24,6 +24,9 @@ page.lottery.main ```powershell .\bin\gsp.exe validate --root examples\lottery .\bin\gsp.exe flatten page.lottery.main --root examples\lottery --depth -1 +.\bin\gsp.exe pack page.lottery.main --root examples\lottery --for implement --format md +.\bin\gsp.exe impact feedback.positive --root examples\lottery --format md +.\bin\gsp.exe message validate messages\implement-page.gspmsg --root examples\lottery .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format mermaid .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format md --out .gsp\lottery-graph.md .\bin\gsp.exe graph page.lottery.main --root examples\lottery --format canvas --out .gsp\lottery-graph.canvas diff --git a/examples/lottery/messages/implement-page.gspmsg b/examples/lottery/messages/implement-page.gspmsg new file mode 100644 index 0000000..d48875c --- /dev/null +++ b/examples/lottery/messages/implement-page.gspmsg @@ -0,0 +1,14 @@ +gspMessageVersion: 0.1 +id: msg.implement.lottery_page +from: planner +to: implementer +intent: implement +entry: page.lottery.main +stage: implement +requires: + - ui.button.reward_primary + - feedback.positive +contextPack: + mode: implement + depth: -1 + budget: 12000 diff --git a/specs/versions/0.1/README.md b/specs/versions/0.1/README.md index 8c4ee65..e211923 100644 --- a/specs/versions/0.1/README.md +++ b/specs/versions/0.1/README.md @@ -13,8 +13,10 @@ README 面向准备使用 GSP 语言的人类和 AI。它说明 GSP 的用途、 | `README.md` | GSP 语言使用前说明。给人类和 AI 阅读。 | | `gsp.schema.json` | GSP 第一版核心字段规范。使用 JSON Schema 表达,便于 AI、工具、编译器和实现模块识别。 | | `gsp.manifest.schema.json` | GSP 工程 manifest 字段规范。 | +| `gsp.message.schema.json` | GSP agent 通信消息字段规范。 | | `commands.md` | GSP Toolkit 命令规范。 | | `ai-usage.md` | GSP 项目 AI 使用规则。 | +| `message.md` | GSP Message 说明。 | ## GSP 是什么 diff --git a/specs/versions/0.1/ai-usage.md b/specs/versions/0.1/ai-usage.md index 9c8b12b..ebb02f1 100644 --- a/specs/versions/0.1/ai-usage.md +++ b/specs/versions/0.1/ai-usage.md @@ -16,4 +16,6 @@ - Use `gsp trace ` to inspect relations. - Use `gsp flatten ` before implementation or task splitting. - Use `gsp pack ` when a compact AI context is needed. +- Use `gsp impact ` before changing shared GSP units. +- Use `gsp message validate ` for agent communication messages. - Use `gsp stage-check --stage ` before stage handoff. diff --git a/specs/versions/0.1/commands.md b/specs/versions/0.1/commands.md index 8d2942a..3364070 100644 --- a/specs/versions/0.1/commands.md +++ b/specs/versions/0.1/commands.md @@ -100,9 +100,55 @@ gsp flatten [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b Create a compact AI context pack. ```bash -gsp pack [--root .] [--depth 3] [--budget 12000] [--out context-pack.json] +gsp pack [--root .] [--for implement] [--stage implement] [--depth 3] [--budget 12000] [--format json|md|canvas] [--out context-pack.json] ``` +The `--for` value describes the task intent: + +```text +design +implement +review +test +acceptance +handoff +inspect +``` + +Formats: + +```text +json Machine-readable context pack. +md Human-readable and AI-readable context pack. +canvas Obsidian JSON Canvas for included GSP relations. +``` + +## impact + +Find direct and indirect GSPs affected by a GSP id. + +```bash +gsp impact [--root .] [--depth -1] [--format json|md|canvas] [--out impact.json] +``` + +Formats: + +```text +json Machine-readable impact result. +md Human-readable impact report. +canvas Obsidian JSON Canvas for affected relations. +``` + +## message + +Validate GSP agent communication messages. + +```bash +gsp message validate [--root .] [--out message-report.json] +``` + +Message files use YAML and reference GSP ids from the project. + ## graph Generate a relation graph. diff --git a/specs/versions/0.1/gsp.message.schema.json b/specs/versions/0.1/gsp.message.schema.json new file mode 100644 index 0000000..04400c3 --- /dev/null +++ b/specs/versions/0.1/gsp.message.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gsp.local/schema/gsp.message.schema.json", + "title": "GSP Message", + "description": "GSP agent communication message schema.", + "type": "object", + "additionalProperties": true, + "required": ["gspMessageVersion", "id", "from", "to", "intent", "entry"], + "properties": { + "gspMessageVersion": { + "type": "string", + "enum": ["0.1"] + }, + "id": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "minLength": 1 + }, + "to": { + "type": "string", + "minLength": 1 + }, + "intent": { + "type": "string", + "enum": ["design", "implement", "review", "test", "acceptance", "handoff", "inspect"] + }, + "entry": { + "type": "string", + "minLength": 1 + }, + "stage": { + "type": "string", + "enum": ["design", "integrate", "implement", "bind", "release"] + }, + "requires": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "contextPack": { + "type": "object", + "additionalProperties": true, + "properties": { + "mode": { + "type": "string", + "enum": ["design", "implement", "review", "test", "acceptance", "handoff", "inspect"] + }, + "depth": { + "type": "integer" + }, + "budget": { + "type": "integer", + "minimum": 0 + } + } + } + } +} diff --git a/specs/versions/0.1/message.md b/specs/versions/0.1/message.md new file mode 100644 index 0000000..7cb4612 --- /dev/null +++ b/specs/versions/0.1/message.md @@ -0,0 +1,41 @@ +# GSP Message 0.1 + +GSP Message is a lightweight agent communication format. + +It references GSP ids and does not replace `.gsp` files. + +```yaml +gspMessageVersion: 0.1 +id: msg.implement.lottery_page +from: planner +to: implementer +intent: implement +entry: page.lottery.main +stage: implement +requires: + - ui.button.reward_primary + - feedback.positive +contextPack: + mode: implement + depth: -1 + budget: 12000 +``` + +Required fields: + +| Field | Meaning | +|---|---| +| `gspMessageVersion` | Message protocol version. | +| `id` | Message id. | +| `from` | Sender agent or module id. | +| `to` | Receiver agent or module id. | +| `intent` | Task intent. | +| `entry` | Entry GSP id. | + +Validation: + +```bash +gsp message validate message.gspmsg --root . +``` + +The validator checks message version, required fields, intent, stage, entry GSP, and required GSP references. diff --git a/toolkit/README.md b/toolkit/README.md index d233245..155e9ca 100644 --- a/toolkit/README.md +++ b/toolkit/README.md @@ -177,6 +177,8 @@ gsp index gsp trace gsp flatten gsp pack +gsp impact +gsp message validate gsp graph gsp stage-check --stage implement ``` @@ -190,6 +192,8 @@ go build -o ../bin/gsp ./cmd/gsp ../bin/gsp version ../bin/gsp validate --root ../examples/lottery ../bin/gsp flatten page.lottery.main --root ../examples/lottery --depth -1 --out ../.gsp/flattened.json +../bin/gsp pack page.lottery.main --root ../examples/lottery --for implement --format md --out ../.gsp/context-pack.md +../bin/gsp impact feedback.positive --root ../examples/lottery --format md --out ../.gsp/impact.md ../bin/gsp graph page.lottery.main --root ../examples/lottery --format mermaid --out ../.gsp/graph.mmd ../bin/gsp graph page.lottery.main --root ../examples/lottery --format md --out ../.gsp/graph.md ../bin/gsp graph page.lottery.main --root ../examples/lottery --format canvas --out ../.gsp/graph.canvas @@ -216,7 +220,9 @@ go build -o ../bin/gsp ./cmd/gsp .gsp/graph.md .gsp/graph.canvas .gsp/context-pack.json +.gsp/context-pack.md .gsp/flattened.json +.gsp/impact.md ``` ## 6. 必须考虑的问题 diff --git a/toolkit/cmd/gsp/completion.go b/toolkit/cmd/gsp/completion.go index 30ed73b..496e055 100644 --- a/toolkit/cmd/gsp/completion.go +++ b/toolkit/cmd/gsp/completion.go @@ -12,6 +12,8 @@ $script:GspSubcommands = @( 'trace', 'flatten', 'pack', + 'impact', + 'message', 'graph', 'stage-check', 'help' @@ -26,7 +28,9 @@ $script:GspFlags = @{ 'index' = @('--root', '--out') 'trace' = @('--root', '--depth', '--out') 'flatten' = @('--root', '--depth', '--include-type', '--exclude-type', '--out') - 'pack' = @('--root', '--depth', '--budget', '--include-type', '--exclude-type', '--out') + 'pack' = @('--root', '--for', '--stage', '--depth', '--budget', '--format', '--include-type', '--exclude-type', '--out') + 'impact' = @('--root', '--depth', '--format', '--out') + 'message' = @('validate') 'graph' = @('--root', '--depth', '--format', '--out') 'stage-check' = @('--stage', '--root', '--out') } @@ -73,7 +77,11 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock { switch ($previous) { '--format' { - return @('json', 'mermaid', 'md', 'canvas') | + $formats = @('json', 'md', 'canvas') + if ($command -eq 'graph') { + $formats = @('json', 'mermaid', 'md', 'canvas') + } + return $formats | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { New-GspCompletion $_ } } @@ -87,6 +95,11 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock { Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { New-GspCompletion $_ } } + '--for' { + return @('design', 'implement', 'review', 'test', 'acceptance', 'handoff', 'inspect') | + Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { New-GspCompletion $_ } + } } if ($command -eq 'completion') { @@ -106,7 +119,13 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock { ForEach-Object { New-GspCompletion $_ } } - if (@('trace', 'flatten', 'pack', 'graph') -contains $command) { + if ($command -eq 'message') { + return @($script:GspFlags[$command]) | + Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { New-GspCompletion $_ } + } + + if (@('trace', 'flatten', 'pack', 'impact', 'graph') -contains $command) { return Get-GspIds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { New-GspCompletion $_ } @@ -125,11 +144,19 @@ _gsp_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" cmd="${COMP_WORDS[1]}" - local commands="init ai-init version completion validate index trace flatten pack graph stage-check help" + local commands="init ai-init version completion validate index trace flatten pack impact message graph stage-check help" case "$prev" in - --format) COMPREPLY=( $(compgen -W "json mermaid md canvas" -- "$cur") ); return ;; + --format) + if [[ "$cmd" == "graph" ]]; then + COMPREPLY=( $(compgen -W "json mermaid md canvas" -- "$cur") ) + else + COMPREPLY=( $(compgen -W "json md canvas" -- "$cur") ) + fi + return + ;; --stage) COMPREPLY=( $(compgen -W "design integrate implement bind release" -- "$cur") ); return ;; --skill) COMPREPLY=( $(compgen -W "generic codex" -- "$cur") ); return ;; + --for) COMPREPLY=( $(compgen -W "design implement review test acceptance handoff inspect" -- "$cur") ); return ;; esac if [[ $COMP_CWORD -eq 1 ]]; then COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) @@ -143,7 +170,9 @@ _gsp_completion() { validate|index) COMPREPLY=( $(compgen -W "--root --out" -- "$cur") ) ;; trace) COMPREPLY=( $(compgen -W "--root --depth --out" -- "$cur") ) ;; flatten) COMPREPLY=( $(compgen -W "--root --depth --include-type --exclude-type --out" -- "$cur") ) ;; - pack) COMPREPLY=( $(compgen -W "--root --depth --budget --include-type --exclude-type --out" -- "$cur") ) ;; + pack) COMPREPLY=( $(compgen -W "--root --for --stage --depth --budget --format --include-type --exclude-type --out" -- "$cur") ) ;; + impact) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;; + message) COMPREPLY=( $(compgen -W "validate" -- "$cur") ) ;; graph) COMPREPLY=( $(compgen -W "--root --depth --format --out" -- "$cur") ) ;; stage-check) COMPREPLY=( $(compgen -W "--stage --root --out" -- "$cur") ) ;; completion) COMPREPLY=( $(compgen -W "powershell bash zsh fish install" -- "$cur") ) ;; @@ -151,7 +180,7 @@ _gsp_completion() { return fi case "$cmd" in - trace|flatten|pack|graph) + trace|flatten|pack|impact|graph) local ids ids=$(gsp index --root . 2>/dev/null | sed -n 's/.*"id": "\([^"]*\)".*/\1/p') COMPREPLY=( $(compgen -W "$ids" -- "$cur") ) @@ -177,6 +206,8 @@ _gsp() { 'trace' 'flatten' 'pack' + 'impact' + 'message' 'graph' 'stage-check' 'help' @@ -189,10 +220,12 @@ _gsp "$@" func fishCompletionScript() string { return `# GSP fish completion -complete -c gsp -f -n '__fish_use_subcommand' -a 'init ai-init version completion validate index trace flatten pack graph stage-check help' +complete -c gsp -f -n '__fish_use_subcommand' -a 'init ai-init version completion validate index trace flatten pack impact message graph stage-check help' complete -c gsp -n '__fish_seen_subcommand_from graph' -l format -a 'json mermaid md canvas' +complete -c gsp -n '__fish_seen_subcommand_from pack impact' -l format -a 'json md canvas' complete -c gsp -n '__fish_seen_subcommand_from stage-check' -l stage -a 'design integrate implement bind release' complete -c gsp -n '__fish_seen_subcommand_from ai-init' -l skill -a 'generic codex' -complete -c gsp -n '__fish_seen_subcommand_from trace flatten pack graph' -a '(gsp index --root . 2>/dev/null | string match -r ''"id": "([^"]+)"'' | string replace -r ''.*"id": "([^"]+)".*'' ''$1'')' +complete -c gsp -n '__fish_seen_subcommand_from pack' -l for -a 'design implement review test acceptance handoff inspect' +complete -c gsp -n '__fish_seen_subcommand_from trace flatten pack impact graph' -a '(gsp index --root . 2>/dev/null | string match -r ''"id": "([^"]+)"'' | string replace -r ''.*"id": "([^"]+)".*'' ''$1'')' ` } diff --git a/toolkit/cmd/gsp/main.go b/toolkit/cmd/gsp/main.go index edb5db9..d570d34 100644 --- a/toolkit/cmd/gsp/main.go +++ b/toolkit/cmd/gsp/main.go @@ -48,6 +48,10 @@ func run(args []string) error { return runFlatten(args[1:]) case "pack": return runPack(args[1:]) + case "impact": + return runImpact(args[1:]) + case "message": + return runMessage(args[1:]) case "graph": return runGraph(args[1:]) case "stage-check": @@ -73,7 +77,9 @@ Usage: gsp index [--root .] [--out index.json] gsp trace [--root .] [--depth 3] [--out trace.json] gsp flatten [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json] - gsp pack [--root .] [--depth 3] [--budget 12000] [--out context-pack.json] + gsp pack [--root .] [--for implement] [--stage implement] [--depth 3] [--budget 12000] [--format json|md|canvas] [--out context-pack.json] + gsp impact [--root .] [--depth -1] [--format json|md|canvas] [--out impact.json] + gsp message validate [--root .] [--out message-report.json] gsp graph [id] [--root .] [--depth 3] [--format json|mermaid|md|canvas] [--out graph.json] gsp stage-check --stage implement [--root .] [--out stage-report.json] `) @@ -320,6 +326,9 @@ func runPack(args []string) error { out := commonOut(fs) depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited") budget := fs.Int("budget", 0, "approximate JSON character budget; 0 means unlimited") + intent := fs.String("for", "", "context purpose: design, implement, review, test, acceptance, handoff, or inspect") + stage := fs.String("stage", "", "optional project stage") + format := fs.String("format", "json", "json, md, or canvas") includeType := fs.String("include-type", "", "comma-separated type allow-list") excludeType := fs.String("exclude-type", "", "comma-separated type deny-list") if err := fs.Parse(normalizeFlagArgs(args)); err != nil { @@ -332,11 +341,91 @@ func runPack(args []string) error { if err != nil { return err } - result := project.Pack(fs.Arg(0), *depth, *budget, gsp.Filter{ + result := project.PackFor(fs.Arg(0), *intent, *stage, *depth, *budget, gsp.Filter{ IncludeTypes: splitCSV(*includeType), ExcludeTypes: splitCSV(*excludeType), }) - return writeJSON(*out, result) + switch *format { + case "json": + return writeJSON(*out, result) + case "md": + return writeText(*out, result.Markdown()) + case "canvas": + data, err := project.Graph(fs.Arg(0), *depth).Canvas() + if err != nil { + return err + } + return writeBytes(*out, data) + default: + return fmt.Errorf("unsupported pack format %q", *format) + } +} + +func runImpact(args []string) error { + fs := flag.NewFlagSet("impact", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + depth := fs.Int("depth", -1, "maximum reverse relation depth; -1 means unlimited") + format := fs.String("format", "json", "json, md, or canvas") + if err := fs.Parse(normalizeFlagArgs(args)); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("impact requires one GSP id") + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + result := project.Impact(fs.Arg(0), *depth) + switch *format { + case "json": + return writeJSON(*out, result) + case "md": + return writeText(*out, result.Markdown()) + case "canvas": + data, err := project.ImpactGraph(result).Canvas() + if err != nil { + return err + } + return writeBytes(*out, data) + default: + return fmt.Errorf("unsupported impact format %q", *format) + } +} + +func runMessage(args []string) error { + if len(args) == 0 { + return fmt.Errorf("message requires validate") + } + switch args[0] { + case "validate": + return runMessageValidate(args[1:]) + default: + return fmt.Errorf("unknown message command %q", args[0]) + } +} + +func runMessageValidate(args []string) error { + fs := flag.NewFlagSet("message validate", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + if err := fs.Parse(normalizeFlagArgs(args)); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("message validate requires one file") + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + message, err := gsp.ReadMessage(project.Root, fs.Arg(0)) + if err != nil { + return err + } + report := project.ValidateMessage(message) + return writeReport(*out, report) } func runGraph(args []string) error { @@ -418,6 +507,7 @@ func normalizeFlagArgs(args []string) []string { "-budget": true, "--budget": true, "-format": true, "--format": true, "-stage": true, "--stage": true, + "-for": true, "--for": true, "-name": true, "--name": true, "-entry": true, "--entry": true, } diff --git a/toolkit/internal/gsp/ai_init.go b/toolkit/internal/gsp/ai_init.go index 6cc765c..dec65c4 100644 --- a/toolkit/internal/gsp/ai_init.go +++ b/toolkit/internal/gsp/ai_init.go @@ -116,6 +116,8 @@ func aiUsage(gspVersion, scan string) string { - Use `+"`gsp trace `"+` to inspect relations. - Use `+"`gsp flatten `"+` before implementation or task splitting. - Use `+"`gsp pack `"+` when a compact AI context is needed. +- Use `+"`gsp impact `"+` before changing shared GSP units. +- Use `+"`gsp message validate `"+` for agent communication messages. - Use `+"`gsp stage-check --stage `"+` before stage handoff. `, gspVersion, scan) diff --git a/toolkit/internal/gsp/format.go b/toolkit/internal/gsp/format.go new file mode 100644 index 0000000..23dccdc --- /dev/null +++ b/toolkit/internal/gsp/format.go @@ -0,0 +1,113 @@ +package gsp + +import ( + "fmt" + "sort" + "strings" +) + +func (p PackResult) Markdown() string { + var builder strings.Builder + builder.WriteString("# GSP Context Pack\n\n") + builder.WriteString(fmt.Sprintf("- Entry: `%s`\n", p.Entry)) + if p.Intent != "" { + builder.WriteString(fmt.Sprintf("- Intent: `%s`\n", p.Intent)) + } + if p.Stage != "" { + builder.WriteString(fmt.Sprintf("- Stage: `%s`\n", p.Stage)) + } + builder.WriteString(fmt.Sprintf("- Units: `%d`\n", p.Summary.UnitCount)) + if p.Summary.MinResolution != "" { + builder.WriteString(fmt.Sprintf("- Resolution: `%s` to `%s`\n", p.Summary.MinResolution, p.Summary.MaxResolution)) + } + if p.Budget > 0 { + builder.WriteString(fmt.Sprintf("- Budget: `%d`\n", p.Budget)) + builder.WriteString(fmt.Sprintf("- Approx chars: `%d`\n", p.ApproxChars)) + builder.WriteString(fmt.Sprintf("- Truncated: `%v`\n", p.Truncated)) + } + builder.WriteString("\n## Units\n\n") + for _, unit := range p.Units { + builder.WriteString(fmt.Sprintf("### %s\n\n", unit.DisplayTitle())) + builder.WriteString(fmt.Sprintf("- id: `%s`\n", unit.ID)) + if unit.Type != "" { + builder.WriteString(fmt.Sprintf("- type: `%s`\n", unit.Type)) + } + if unit.Resolution != "" { + builder.WriteString(fmt.Sprintf("- resolution: `%s`\n", unit.Resolution)) + } + if unit.Refines != "" { + builder.WriteString(fmt.Sprintf("- refines: `%s`\n", unit.Refines)) + } + if len(unit.With) > 0 { + var with []string + for _, rel := range unit.With { + with = append(with, "`"+rel.ID+"`") + } + sort.Strings(with) + builder.WriteString("- with: " + strings.Join(with, ", ") + "\n") + } + if unit.Context != "" { + builder.WriteString("\n") + builder.WriteString(unit.Context) + builder.WriteString("\n") + } + builder.WriteString("\n") + } + if len(p.Warnings) > 0 { + builder.WriteString("## Warnings\n\n") + for _, warning := range p.Warnings { + builder.WriteString(fmt.Sprintf("- `%s`: %s\n", warning.Code, warning.Message)) + } + } + return builder.String() +} + +func (r ImpactResult) Markdown() string { + var builder strings.Builder + builder.WriteString("# GSP Impact\n\n") + builder.WriteString(fmt.Sprintf("- Entry: `%s`\n", r.Entry)) + builder.WriteString(fmt.Sprintf("- Direct affected: `%d`\n", r.Summary.DirectCount)) + builder.WriteString(fmt.Sprintf("- Indirect affected: `%d`\n", r.Summary.IndirectCount)) + builder.WriteString(fmt.Sprintf("- Max depth: `%d`\n", r.Summary.MaxDepth)) + writeImpactSection(&builder, "Direct", r.Direct) + writeImpactSection(&builder, "Indirect", r.Indirect) + if len(r.Warnings) > 0 { + builder.WriteString("## Warnings\n\n") + for _, warning := range r.Warnings { + builder.WriteString(fmt.Sprintf("- `%s`: %s\n", warning.Code, warning.Message)) + } + } + return builder.String() +} + +func writeImpactSection(builder *strings.Builder, title string, entries []ImpactEntry) { + builder.WriteString("\n## " + title + "\n\n") + if len(entries) == 0 { + builder.WriteString("None.\n") + return + } + for _, entry := range entries { + label := entry.Title + if label == "" { + label = entry.ID + } + builder.WriteString(fmt.Sprintf("- `%s` %s", entry.ID, label)) + if entry.Kind != "" || entry.Via != "" { + builder.WriteString(fmt.Sprintf(" via `%s` `%s`", entry.Kind, entry.Via)) + } + if entry.Type != "" { + builder.WriteString(fmt.Sprintf(" type `%s`", entry.Type)) + } + if entry.Resolution != "" { + builder.WriteString(fmt.Sprintf(" resolution `%s`", entry.Resolution)) + } + builder.WriteString("\n") + } +} + +func (u Unit) DisplayTitle() string { + if strings.TrimSpace(u.Title) != "" { + return u.Title + } + return u.ID +} diff --git a/toolkit/internal/gsp/graph.go b/toolkit/internal/gsp/graph.go index 3cd95b3..146ab36 100644 --- a/toolkit/internal/gsp/graph.go +++ b/toolkit/internal/gsp/graph.go @@ -96,6 +96,33 @@ func (g Graph) Markdown() string { return builder.String() } +func (p *Project) ImpactGraph(impact ImpactResult) Graph { + nodeMap := map[string]GraphNode{} + if unit, ok := p.ByID[impact.Entry]; ok { + nodeMap[impact.Entry] = GraphNode{ID: unit.ID, Title: unit.Title, Type: unit.Type, Resolution: unit.Resolution, File: unit.File} + } else { + nodeMap[impact.Entry] = GraphNode{ID: impact.Entry, Missing: true} + } + for _, entry := range append(append([]ImpactEntry{}, impact.Direct...), impact.Indirect...) { + nodeMap[entry.ID] = GraphNode{ID: entry.ID, Title: entry.Title, Type: entry.Type, Resolution: entry.Resolution, File: entry.File} + if _, ok := nodeMap[entry.Via]; !ok && entry.Via != "" { + if unit, exists := p.ByID[entry.Via]; exists { + nodeMap[entry.Via] = GraphNode{ID: unit.ID, Title: unit.Title, Type: unit.Type, Resolution: unit.Resolution, File: unit.File} + } else { + nodeMap[entry.Via] = GraphNode{ID: entry.Via, Missing: true} + } + } + } + nodes := make([]GraphNode, 0, len(nodeMap)) + for _, node := range nodeMap { + nodes = append(nodes, node) + } + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].ID < nodes[j].ID + }) + return Graph{Nodes: nodes, Edges: impact.Edges} +} + func (g Graph) Canvas() ([]byte, error) { canvas := canvasDocument{ Nodes: make([]canvasNode, 0, len(g.Nodes)), diff --git a/toolkit/internal/gsp/message.go b/toolkit/internal/gsp/message.go new file mode 100644 index 0000000..19834ff --- /dev/null +++ b/toolkit/internal/gsp/message.go @@ -0,0 +1,99 @@ +package gsp + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const GSPMessageVersion = "0.1" + +var allowedMessageIntents = map[string]bool{ + "design": true, + "implement": true, + "review": true, + "test": true, + "acceptance": true, + "handoff": true, + "inspect": true, +} + +type Message struct { + MessageVersion string `json:"gspMessageVersion,omitempty" yaml:"gspMessageVersion"` + ID string `json:"id,omitempty" yaml:"id"` + From string `json:"from,omitempty" yaml:"from"` + To string `json:"to,omitempty" yaml:"to"` + Intent string `json:"intent,omitempty" yaml:"intent"` + Entry string `json:"entry,omitempty" yaml:"entry"` + Stage string `json:"stage,omitempty" yaml:"stage"` + Requires []string `json:"requires,omitempty" yaml:"requires"` + ContextPack ContextPack `json:"contextPack,omitempty" yaml:"contextPack"` + File string `json:"file,omitempty" yaml:"-"` +} + +type ContextPack struct { + Mode string `json:"mode,omitempty" yaml:"mode"` + Depth int `json:"depth,omitempty" yaml:"depth"` + Budget int `json:"budget,omitempty" yaml:"budget"` +} + +func ReadMessage(root, file string) (Message, error) { + path := file + if !filepath.IsAbs(path) { + path = filepath.Join(root, file) + } + data, err := os.ReadFile(path) + if err != nil { + return Message{}, err + } + var message Message + if err := yaml.Unmarshal(data, &message); err != nil { + return Message{}, err + } + message.File = relPath(root, path) + return message, nil +} + +func (p *Project) ValidateMessage(message Message) Report { + report := Report{OK: true} + if message.MessageVersion == "" { + report.addError("missing_message_version", message.ID, message.File, "message requires gspMessageVersion") + } else if message.MessageVersion != GSPMessageVersion { + report.addError("unsupported_message_version", message.ID, message.File, fmt.Sprintf("GSP message version %q is not supported", message.MessageVersion)) + } + if message.ID == "" { + report.addError("missing_id", "", message.File, "message requires id") + } + if message.From == "" { + report.addError("missing_from", message.ID, message.File, "message requires from") + } + if message.To == "" { + report.addError("missing_to", message.ID, message.File, "message requires to") + } + if message.Intent == "" { + report.addError("missing_intent", message.ID, message.File, "message requires intent") + } else if !allowedMessageIntents[message.Intent] { + report.addError("invalid_intent", message.ID, message.File, fmt.Sprintf("intent %q is not allowed", message.Intent)) + } + if message.Entry == "" { + report.addError("missing_entry", message.ID, message.File, "message requires entry") + } else if _, ok := p.ByID[message.Entry]; !ok { + report.addError("missing_entry_gsp", message.Entry, message.File, fmt.Sprintf("entry references missing GSP %q", message.Entry)) + } + if message.Stage != "" { + if _, ok := p.Manifest.minResolution(message.Stage); !ok { + report.addError("unknown_stage", message.ID, message.File, fmt.Sprintf("unknown stage %q", message.Stage)) + } + } + for _, id := range message.Requires { + if _, ok := p.ByID[id]; !ok { + report.addError("missing_required_gsp", id, message.File, fmt.Sprintf("requires references missing GSP %q", id)) + } + } + if message.ContextPack.Mode != "" && !allowedMessageIntents[message.ContextPack.Mode] { + report.addError("invalid_context_pack_mode", message.ID, message.File, fmt.Sprintf("contextPack mode %q is not allowed", message.ContextPack.Mode)) + } + return report +} diff --git a/toolkit/internal/gsp/model.go b/toolkit/internal/gsp/model.go index 0d8c22e..66a36e3 100644 --- a/toolkit/internal/gsp/model.go +++ b/toolkit/internal/gsp/model.go @@ -131,14 +131,53 @@ type TraceResult struct { type PackResult struct { Entry string `json:"entry"` + Intent string `json:"intent,omitempty"` + Stage string `json:"stage,omitempty"` Depth int `json:"depth"` Budget int `json:"budget,omitempty"` Truncated bool `json:"truncated"` Units []*Unit `json:"units"` + Summary Summary `json:"summary"` ApproxChars int `json:"approxChars"` Warnings []Issue `json:"warnings,omitempty"` } +type Summary struct { + UnitCount int `json:"unitCount"` + MinResolution string `json:"minResolution,omitempty"` + MaxResolution string `json:"maxResolution,omitempty"` + TypeCounts map[string]int `json:"typeCounts,omitempty"` + MissingCount int `json:"missingCount,omitempty"` +} + +type ImpactResult struct { + Entry string `json:"entry"` + Depth int `json:"depth"` + Summary ImpactSummary `json:"summary"` + Direct []ImpactEntry `json:"direct,omitempty"` + Indirect []ImpactEntry `json:"indirect,omitempty"` + Edges []GraphEdge `json:"edges,omitempty"` + Warnings []Issue `json:"warnings,omitempty"` +} + +type ImpactSummary struct { + DirectCount int `json:"directCount"` + IndirectCount int `json:"indirectCount"` + TotalCount int `json:"totalCount"` + MaxDepth int `json:"maxDepth"` +} + +type ImpactEntry struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Type string `json:"type,omitempty"` + Resolution string `json:"resolution,omitempty"` + File string `json:"file,omitempty"` + Depth int `json:"depth"` + Via string `json:"via,omitempty"` + Kind string `json:"kind,omitempty"` +} + type Graph struct { Nodes []GraphNode `json:"nodes"` Edges []GraphEdge `json:"edges"` diff --git a/toolkit/internal/gsp/project.go b/toolkit/internal/gsp/project.go index ae9f3a1..1777428 100644 --- a/toolkit/internal/gsp/project.go +++ b/toolkit/internal/gsp/project.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" ) func (p *Project) Validate(stage string) Report { @@ -165,6 +166,10 @@ func (p *Project) Flatten(id string, depth int, filter Filter) FlattenResult { } func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult { + return p.PackFor(id, "", "", depth, budget, filter) +} + +func (p *Project) PackFor(id, intent, stage string, depth, budget int, filter Filter) PackResult { flattened := p.Flatten(id, depth, filter) units := make([]*Unit, 0, len(flattened.Units)) approx := 0 @@ -181,15 +186,131 @@ func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult { } return PackResult{ Entry: id, + Intent: intent, + Stage: stage, Depth: depth, Budget: budget, Truncated: truncated, Units: units, + Summary: summarizeUnits(units, len(flattened.Warnings)), ApproxChars: approx, Warnings: flattened.Warnings, } } +func summarizeUnits(units []*Unit, missingCount int) Summary { + summary := Summary{ + UnitCount: len(units), + TypeCounts: map[string]int{}, + MissingCount: missingCount, + } + min := 99 + max := -1 + for _, unit := range units { + if unit.Type != "" { + summary.TypeCounts[unit.Type]++ + } + rank, ok := resolutionValue(unit.Resolution) + if !ok { + continue + } + if rank < min { + min = rank + summary.MinResolution = displayResolution(unit.Resolution) + } + if rank > max { + max = rank + summary.MaxResolution = displayResolution(unit.Resolution) + } + } + if len(summary.TypeCounts) == 0 { + summary.TypeCounts = nil + } + return summary +} + +func (p *Project) Impact(id string, depth int) ImpactResult { + result := ImpactResult{Entry: id, Depth: depth} + if _, ok := p.ByID[id]; !ok { + result.Warnings = append(result.Warnings, Issue{Level: "warning", Code: "missing", ID: id, Message: fmt.Sprintf("missing GSP %q", id)}) + return result + } + + reverse := map[string][]ImpactEntry{} + for _, unit := range p.Units { + if unit.Refines != "" { + reverse[unit.Refines] = append(reverse[unit.Refines], impactEntry(unit, 0, unit.Refines, "refines")) + } + for _, rel := range unit.With { + reverse[rel.ID] = append(reverse[rel.ID], impactEntry(unit, 0, rel.ID, "with")) + } + } + for key := range reverse { + sort.Slice(reverse[key], func(i, j int) bool { + return reverse[key][i].ID < reverse[key][j].ID + }) + } + + seen := map[string]bool{id: true} + queue := []ImpactEntry{{ID: id, Depth: 0}} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if depth >= 0 && current.Depth >= depth { + continue + } + for _, affected := range reverse[current.ID] { + if seen[affected.ID] { + continue + } + seen[affected.ID] = true + affected.Depth = current.Depth + 1 + affected.Via = current.ID + if affected.Depth == 1 { + result.Direct = append(result.Direct, affected) + } else { + result.Indirect = append(result.Indirect, affected) + } + result.Edges = append(result.Edges, GraphEdge{From: affected.ID, To: current.ID, Kind: affected.Kind}) + queue = append(queue, affected) + if affected.Depth > result.Summary.MaxDepth { + result.Summary.MaxDepth = affected.Depth + } + } + } + sortImpactEntries(result.Direct) + sortImpactEntries(result.Indirect) + sort.Slice(result.Edges, func(i, j int) bool { + return strings.Join([]string{result.Edges[i].From, result.Edges[i].To, result.Edges[i].Kind}, "\x00") < strings.Join([]string{result.Edges[j].From, result.Edges[j].To, result.Edges[j].Kind}, "\x00") + }) + result.Summary.DirectCount = len(result.Direct) + result.Summary.IndirectCount = len(result.Indirect) + result.Summary.TotalCount = len(result.Direct) + len(result.Indirect) + return result +} + +func impactEntry(unit *Unit, depth int, via, kind string) ImpactEntry { + return ImpactEntry{ + ID: unit.ID, + Title: unit.Title, + Type: unit.Type, + Resolution: unit.Resolution, + File: unit.File, + Depth: depth, + Via: via, + Kind: kind, + } +} + +func sortImpactEntries(entries []ImpactEntry) { + sort.Slice(entries, func(i, j int) bool { + if entries[i].Depth == entries[j].Depth { + return entries[i].ID < entries[j].ID + } + return entries[i].Depth < entries[j].Depth + }) +} + type walker struct { project *Project depth int diff --git a/toolkit/internal/gsp/project_test.go b/toolkit/internal/gsp/project_test.go index 34b2636..aa0c25b 100644 --- a/toolkit/internal/gsp/project_test.go +++ b/toolkit/internal/gsp/project_test.go @@ -91,6 +91,25 @@ context: Base lottery page. if !foundTitle { t.Fatalf("expected canvas node text to include title, got %+v", canvas.Nodes) } + + pack := project.PackFor("page.lottery.main", "implement", "implement", -1, 0, Filter{}) + if pack.Summary.UnitCount != 4 { + t.Fatalf("pack unit count = %d", pack.Summary.UnitCount) + } + if !strings.Contains(pack.Markdown(), "GSP Context Pack") { + t.Fatal("expected pack markdown") + } + + impact := project.Impact("feedback.positive", -1) + if impact.Summary.DirectCount != 1 { + t.Fatalf("impact direct count = %d", impact.Summary.DirectCount) + } + if impact.Direct[0].ID != "page.lottery.main" { + t.Fatalf("impact direct = %+v", impact.Direct) + } + if !strings.Contains(impact.Markdown(), "GSP Impact") { + t.Fatal("expected impact markdown") + } } func TestValidateMissingReference(t *testing.T) { @@ -263,6 +282,40 @@ func TestInitAIUsageRequiresManifest(t *testing.T) { } } +func TestValidateMessage(t *testing.T) { + root := t.TempDir() + if _, err := InitProject(root, InitOptions{ + Entry: "page.sample.main", + }); err != nil { + t.Fatal(err) + } + writeTestFile(t, root, "message.gspmsg", `gspMessageVersion: 0.1 +id: msg.sample +from: planner +to: implementer +intent: implement +entry: page.sample.main +stage: implement +requires: + - page.sample.main +contextPack: + mode: implement + depth: -1 +`) + project, err := LoadProject(root) + if err != nil { + t.Fatal(err) + } + message, err := ReadMessage(project.Root, "message.gspmsg") + if err != nil { + t.Fatal(err) + } + report := project.ValidateMessage(message) + if !report.OK { + t.Fatalf("expected valid message, got %+v", report.Errors) + } +} + func writeTestFile(t *testing.T, root, name, content string) { t.Helper() path := filepath.Join(root, name)