Skip to content

Commit 2caa0d7

Browse files
committed
Add experimental read-only plan inspection
1 parent c6846f6 commit 2caa0d7

11 files changed

Lines changed: 1359 additions & 0 deletions

File tree

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,52 @@ agent "<identity>" {
183183
}
184184
```
185185

186+
## Experimental read-only plans
187+
188+
The experimental `st2 plan` surface implements only the inspectable file model from the
189+
[st2 plans sketch at revision `5c1d142`](https://gist.github.com/myobie/d5ecfac24cd3965e095a5031cd2e00cb/5c1d1427c0556d95d13890e5c5086cd85b25d994).
190+
It parses either a top-level `plan.kdl`:
191+
192+
```kdl
193+
plan "ship-remote-approvals" {
194+
owner "app-web"
195+
version "0000" resource="file:versions/0000.md"
196+
version "0001" resource="file:versions/0001.md" {
197+
parent "0000"
198+
why "Browser proof exposed an approval race."
199+
}
200+
}
201+
```
202+
203+
or the equivalent agent-local form:
204+
205+
```kdl
206+
agent "app-web" {
207+
plan "review-follow-up" {
208+
version "0000" resource="file:review-follow-up.md"
209+
}
210+
}
211+
```
212+
213+
An agent links an external declaration with `plan-ref "file:relative/plan.kdl"`. Plan identity is
214+
the explicit KDL value, never the directory. Inline ownership comes from the containing agent;
215+
external plans require one `owner`. Every version has one relative `file:` resource. Revisions have
216+
one or more immutable `parent` links and a non-empty `why`; root versions have no parent. The
217+
frontier is derived as every version with no child, so concurrent siblings remain visible.
218+
References resolve from the KDL file that declares them and must stay inside the selected catalog.
219+
220+
```sh
221+
st2 plan validate --catalog examples/plans
222+
st2 plan list --catalog examples/plans
223+
st2 plan show ship-remote-approvals --catalog examples/plans
224+
st2 plan inspect ship-remote-approvals --catalog examples/plans --json
225+
```
226+
227+
All four commands are read-only. They do not select a current version, write progress, emit events,
228+
execute a plan, reconcile agents, schedule work, interpret claims, or require CAS. Direct KDL and
229+
direct human-to-agent planning remain complete workflows; this experiment must earn any larger
230+
runtime.
231+
186232
st2 provides `CATALOG`, flat native `ST_ROOT`, local `PTY_ROOT`, `ST_AGENT`, and `ST_HOOKS` to the
187233
task. The complete st2-managed overlay is also persisted in PTY metadata, so a manual `pty restart`
188234
retains those values. Declarations should not contain machine-specific install paths.

docs/vrs/spec.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,23 @@ atomic inbox file → DING attempt → agent reads → archive receipt
208208
- **R10:** Fleet identities are agents. General-purpose identity kinds are
209209
unsupported.
210210

211+
### Experimental read-only plan inspection
212+
213+
`st2 plan validate|list|show|inspect` is a non-executing probe for the
214+
[source plan sketch at revision `5c1d142`](https://gist.github.com/myobie/d5ecfac24cd3965e095a5031cd2e00cb/5c1d1427c0556d95d13890e5c5086cd85b25d994).
215+
It normalizes top-level external plans and agent-local inline plans into one
216+
model with explicit plan identity, owner, immutable version/parent links,
217+
mandatory reasons on revisions, source-relative `file:` resources, and a
218+
derived frontier that retains concurrent siblings. External `plan-ref` and
219+
version resources resolve from the declaring KDL file and remain within the
220+
selected catalog.
221+
222+
The parser is deliberately deny-by-default for plan fields beyond that model.
223+
It has no current pointer, controller, execution, scheduling, step graph,
224+
retry, claim, receipt, event, reconciliation, CAS, or mutation behavior. It
225+
does not yet fulfill R08 plan-progress observability or resolve DQ3; direct KDL
226+
and direct human-to-agent planning remain first-class.
227+
211228
The owner updates this spec whenever implementation changes.
212229
Changing [vision.md](./vision.md) or [requirements.md](./requirements.md)
213230
requires Nathan's explicit approval.

examples/plans/agent.kdl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
agent "app-web" {
2+
host "example"
3+
command "true"
4+
plan-ref "file:ship-remote-approvals/plan.kdl"
5+
}
6+
7+
agent "reviewer" {
8+
host "example"
9+
command "true"
10+
plan "review-follow-up" {
11+
version "0000" resource="file:review-follow-up.md"
12+
}
13+
}

examples/plans/review-follow-up.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Review follow-up
2+
3+
Done means the accepted review corrections are present and independently verified.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
plan "ship-remote-approvals" {
2+
owner "app-web"
3+
4+
version "0000" resource="file:versions/0000.md"
5+
version "0001" resource="file:versions/0001.md" {
6+
parent "0000"
7+
why "Browser proof exposed an approval race."
8+
}
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Ship remote approvals
2+
3+
Done means an authorized reviewer can approve the exact remote change.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Ship remote approvals
2+
3+
Done means an authorized reviewer can approve the exact remote change without racing stale state.

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod host_lock;
1919
pub mod isolate;
2020
pub mod materialize;
2121
pub mod message;
22+
pub mod plans;
2223
pub mod pretrust;
2324
pub mod reconcile;
2425
pub mod resource;

src/main.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ enum Command {
269269
#[command(flatten)]
270270
ctx: MsgCtx,
271271
},
272+
/// EXPERIMENTAL, READ-ONLY: parse, validate, and inspect versioned catalog plans.
273+
#[command(subcommand)]
274+
Plan(PlanCmd),
272275
/// Print a shell completion script for `st2` to stdout (`st2 completions <bash|zsh|fish|…>`).
273276
/// Generated from the live command tree, so it never drifts from the actual flags.
274277
Completions {
@@ -339,6 +342,48 @@ enum HooksCmd {
339342
VerifyOwn,
340343
}
341344

345+
#[derive(Subcommand)]
346+
enum PlanCmd {
347+
/// Validate every external and inline plan without executing or writing anything.
348+
Validate {
349+
/// Catalog folder or KDL file. Prefer --catalog; defaults to the selected catalog.
350+
#[arg(conflicts_with = "catalog_path")]
351+
root: Option<PathBuf>,
352+
/// Emit a machine-readable validation receipt.
353+
#[arg(long)]
354+
json: bool,
355+
},
356+
/// List normalized plan identity, owner, and derived frontier.
357+
List {
358+
/// Catalog folder or KDL file. Prefer --catalog; defaults to the selected catalog.
359+
#[arg(conflicts_with = "catalog_path")]
360+
root: Option<PathBuf>,
361+
/// Emit a machine-readable array.
362+
#[arg(long)]
363+
json: bool,
364+
},
365+
/// Show normalized intent for one explicit plan identity.
366+
Show {
367+
identity: String,
368+
/// Catalog folder or KDL file. Prefer --catalog; defaults to the selected catalog.
369+
#[arg(conflicts_with = "catalog_path")]
370+
root: Option<PathBuf>,
371+
/// Emit machine-readable normalized intent.
372+
#[arg(long)]
373+
json: bool,
374+
},
375+
/// Inspect one plan with source provenance, resolved file paths, and agent references.
376+
Inspect {
377+
identity: String,
378+
/// Catalog folder or KDL file. Prefer --catalog; defaults to the selected catalog.
379+
#[arg(conflicts_with = "catalog_path")]
380+
root: Option<PathBuf>,
381+
/// Emit the complete machine-readable inspection record.
382+
#[arg(long)]
383+
json: bool,
384+
},
385+
}
386+
342387
#[derive(Subcommand)]
343388
enum ResourceCmd {
344389
/// Link a resource (a URL you produced or reference) into your resource list.
@@ -571,6 +616,7 @@ fn main() -> Result<()> {
571616
enrich,
572617
ctx,
573618
} => agents_cmd(catalog, status, json, enrich, ctx),
619+
Command::Plan(command) => plan_cmd(command),
574620
Command::CompileAgent {
575621
catalog,
576622
identity,
@@ -638,6 +684,159 @@ fn main() -> Result<()> {
638684
}
639685
}
640686

687+
fn plan_cmd(command: PlanCmd) -> Result<()> {
688+
match command {
689+
PlanCmd::Validate { root, json } => {
690+
let root = catalog_arg(root)?;
691+
match st2::plans::load(&root) {
692+
Ok(catalog) => {
693+
if json {
694+
println!(
695+
"{}",
696+
serde_json::to_string_pretty(&serde_json::json!({
697+
"result": "valid",
698+
"plans": catalog.plans.len(),
699+
"errors": 0,
700+
}))?
701+
);
702+
} else {
703+
println!(
704+
"valid: {} plan{}; read-only (no execution or writes)",
705+
catalog.plans.len(),
706+
plural(catalog.plans.len())
707+
);
708+
}
709+
Ok(())
710+
}
711+
Err(error) => {
712+
if json {
713+
println!(
714+
"{}",
715+
serde_json::to_string_pretty(&serde_json::json!({
716+
"result": "invalid",
717+
"code": error.code(),
718+
"path": error.path(),
719+
"error": error.to_string(),
720+
}))?
721+
);
722+
}
723+
Err(error.into())
724+
}
725+
}
726+
}
727+
PlanCmd::List { root, json } => {
728+
let catalog = st2::plans::load(&catalog_arg(root)?)?;
729+
if json {
730+
let rows = catalog
731+
.plans
732+
.iter()
733+
.map(|plan| {
734+
serde_json::json!({
735+
"identity": plan.identity,
736+
"owner": plan.owner,
737+
"frontier": plan.frontier,
738+
})
739+
})
740+
.collect::<Vec<_>>();
741+
println!("{}", serde_json::to_string_pretty(&rows)?);
742+
} else {
743+
for plan in catalog.plans {
744+
println!(
745+
"{}\towner={}\tfrontier={}",
746+
plan.identity,
747+
plan.owner,
748+
plan.frontier.join(",")
749+
);
750+
}
751+
}
752+
Ok(())
753+
}
754+
PlanCmd::Show {
755+
identity,
756+
root,
757+
json,
758+
} => {
759+
let catalog = st2::plans::load(&catalog_arg(root)?)?;
760+
let plan = exact_plan(&catalog, &identity)?;
761+
let intent = serde_json::json!({
762+
"identity": plan.identity,
763+
"owner": plan.owner,
764+
"versions": plan.versions.iter().map(|version| serde_json::json!({
765+
"identity": version.identity,
766+
"parents": version.parents,
767+
"why": version.why,
768+
"resource": version.resource,
769+
})).collect::<Vec<_>>(),
770+
"frontier": plan.frontier,
771+
});
772+
if json {
773+
println!("{}", serde_json::to_string_pretty(&intent)?);
774+
} else {
775+
println!("plan {} owner={}", plan.identity, plan.owner);
776+
for version in &plan.versions {
777+
let marker = if plan.frontier.contains(&version.identity) {
778+
" [frontier]"
779+
} else {
780+
""
781+
};
782+
println!(
783+
" version {}{marker} resource={}",
784+
version.identity, version.resource
785+
);
786+
if !version.parents.is_empty() {
787+
println!(" parents: {}", version.parents.join(", "));
788+
}
789+
if let Some(why) = &version.why {
790+
println!(" why: {why}");
791+
}
792+
}
793+
}
794+
Ok(())
795+
}
796+
PlanCmd::Inspect {
797+
identity,
798+
root,
799+
json,
800+
} => {
801+
let catalog = st2::plans::load(&catalog_arg(root)?)?;
802+
let plan = exact_plan(&catalog, &identity)?;
803+
if json {
804+
println!("{}", serde_json::to_string_pretty(plan)?);
805+
} else {
806+
println!(
807+
"plan {} owner={} kind={:?}\n source: {}\n referenced-by: {}\n frontier: {}",
808+
plan.identity,
809+
plan.owner,
810+
plan.source_kind,
811+
plan.source.display(),
812+
plan.referenced_by.join(","),
813+
plan.frontier.join(",")
814+
);
815+
for version in &plan.versions {
816+
println!(
817+
" {}: {} -> {}",
818+
version.identity,
819+
version.resource,
820+
version.resolved_resource.display()
821+
);
822+
}
823+
}
824+
Ok(())
825+
}
826+
}
827+
}
828+
829+
fn exact_plan<'a>(
830+
catalog: &'a st2::plans::PlanCatalog,
831+
identity: &str,
832+
) -> Result<&'a st2::plans::Plan> {
833+
catalog
834+
.plans
835+
.iter()
836+
.find(|plan| plan.identity == identity)
837+
.with_context(|| format!("no plan '{identity}' found"))
838+
}
839+
641840
#[allow(clippy::too_many_arguments)]
642841
fn compile_agent_cmd(
643842
catalog: &Path,

0 commit comments

Comments
 (0)