mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-08 16:28:25 +02:00
Compare commits
17 Commits
25ee5f3d30
...
5d072d21e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d072d21e9 | |||
| d5f0d6ed3e | |||
| 4c3cb0f347 | |||
| c592313d9a | |||
| ad982d20c2 | |||
| b3242e8c04 | |||
| d4494a8aeb | |||
| cc86f54d65 | |||
| db80c9b96e | |||
| 4c16a42f39 | |||
| 29dcd478a0 | |||
| 425d94ee43 | |||
| 8f44ad308d | |||
| fa29909f05 | |||
| 9757fef8a7 | |||
| a0c6c8ba53 | |||
| 49d5b3fcdc |
@@ -22,6 +22,7 @@ on:
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
pull_request:
|
||||
@@ -43,6 +44,7 @@ on:
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
workflow_dispatch:
|
||||
@@ -76,6 +78,8 @@ jobs:
|
||||
run: python .github/scripts/check_release_readiness.py
|
||||
- name: Check ROADMAP ids
|
||||
run: scripts/roadmap-check-ids.sh
|
||||
- name: Check ROADMAP helper behavior
|
||||
run: python -m unittest discover -s tests -p test_roadmap_helpers.py
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
|
||||
+32
@@ -7615,3 +7615,35 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
723. **Concurrent dogfood claws allocate ROADMAP ids manually and collide — same id reused by two contributors simultaneously, causing PR ROADMAP.md conflicts and lost entries** — observed live 2026-05-26 during Jobdori+Gaebal parallel dogfood session: Gaebal filed stale-local-probe as #719; Jobdori landed `plugins list <filter>` as #719 on main first; Gaebal shifted to #720; Jobdori landed `claw help <topic>` as #720; stale-local-probe eventually landed as #721 after two forced rebase cycles. The ROADMAP append workflow has no reservation or conflict-aware id allocation. **Required fix shape:** (a) add `scripts/roadmap-next-id.sh` that reads the highest id from ROADMAP.md and prints `highest+1` — claws should call this immediately before appending any new entry; (b) document in CONTRIBUTING.md that id allocation is optimistic-append: call `roadmap-next-id.sh` immediately before the append, git-pull first, resolve collisions at push time by re-numbering the appended entry; (c) long-term: a GitHub Action that validates no duplicate ROADMAP ids on PR would catch this before merge. Added `scripts/roadmap-next-id.sh` (this commit). Source: Gaebal Gajae live observation, 2026-05-26.
|
||||
|
||||
724. **DONE — ROADMAP duplicate-id validation guard for helper-era append collisions** — follow-up to #723 after dogfood showed `scripts/roadmap-next-id.sh` still printed 724 and exited 0 when a temp ROADMAP copy already contained a second `723. ...` line. This PR closes the gap for new optimistic-append collisions by adding `scripts/roadmap-check-ids.sh`, wiring it into docs CI and the local pre-push hook, documenting the pre-push command in CONTRIBUTING, and mentioning the guard from `roadmap-next-id.sh`. The guard defaults to ids >=723 so current historical roadmap content and old numbered lists do not block docs-only PRs; `--min-id 1` is available for a strict whole-file audit once legacy collisions are cleaned up. **Verification:** `scripts/roadmap-check-ids.sh` passes on current ROADMAP; a temp copy with an appended duplicate `723.` fails nonzero and lists duplicate id 723 with line numbers. Source: Jobdori dogfood follow-up on origin/main `922c2398`, 2026-05-25. [SCOPE: docs/scripts]
|
||||
|
||||
725. **DONE — roadmap-next-id helper now fails closed on helper-era duplicate ids before printing a next id** — follow-up to #724 after dogfood on origin/main 25ee5f3d showed `scripts/roadmap-next-id.sh` could print `1000` and exit 0 when a temp ROADMAP copy already contained two `999.` helper-era entries. This PR makes `roadmap-next-id.sh` resolve `roadmap-check-ids.sh` by its own script directory, run the checker with default helper-era min-id semantics before computing `highest+1`, keep stdout reserved for the single next id on success, and fail closed with a useful error if the checker is unavailable. Added focused pytest coverage for clean next-id output, duplicate fail-fast behavior, and missing-checker fail-closed behavior. **Verification:** `scripts/roadmap-next-id.sh ROADMAP.md` prints `725`; `scripts/roadmap-check-ids.sh ROADMAP.md` passes; a temp ROADMAP with duplicate `999.` exits nonzero and lists duplicate id 999 without printing a next id; `bash -n scripts/roadmap-next-id.sh scripts/roadmap-check-ids.sh` passes; `python -m pytest tests/test_roadmap_helpers.py -q` passes. Source: Jobdori dogfood follow-up on origin/main 25ee5f3d. [SCOPE: docs/scripts]
|
||||
|
||||
726. **`claw export` from a workspace with a cross-workspace legacy session emits `kind:"unknown", error_kind:"unknown"` instead of a typed error — `legacy session is missing workspace binding` error propagates through the generic error handler unclassified** — dogfooded 2026-05-26 on `d8a61090`. Reproduction: `claw export --output-format json` from a fresh `git init` workspace where the most-recent managed session was created in a different workspace root returns `{kind:"unknown", action:"abort", status:"error", error_kind:"unknown"}`. The error originates in `SessionControlError::Format(format_legacy_session_missing_workspace_root(...))` in `session_control.rs:313`; `classify_error_kind` had no branch for "legacy session is missing workspace binding" and fell through to "unknown". Fix: added `legacy_session_no_workspace_binding` branch to `classify_error_kind`. Remaining gap: `kind` still shows the error_kind value instead of `"export"` — root cause is the generic error path setting `kind = error_kind` rather than the subcommand name; this is the `#422` class and requires a separate structural fix. Source: Jobdori dogfood on `d8a61090`, 2026-05-26.
|
||||
|
||||
727. **`branch_freshness.fresh: null` with `upstream: null` is ambiguous — automation checking `if .workspace.branch_freshness.fresh == true` treats "no upstream configured" identically to "behind by N commits", both returning falsy null** — dogfooded 2026-05-26 on `a0c6c8ba`. Reproduction: `claw status --output-format json` from a freshly `git init`'d repo with no remote returns `{upstream: null, fresh: null, ahead: 0, behind: 0}`. An automation script that gates on `.branch_freshness.fresh == true` before proceeding sees `null == true → false` and blocks — identical to the behind-by-N case. The JSON has no discriminator between "freshness unknown because no upstream" and "freshness unknown because git unavailable". Fix: added `has_upstream: bool` to `BranchFreshness.json_value()` — automation should check `has_upstream` before branching on `fresh`. Source: Jobdori dogfood on `a0c6c8ba`, 2026-05-26.
|
||||
|
||||
728. **`claw agents list` and `agents show` JSON responses had no `path` field — callers could not determine which on-disk `.toml` file backs each agent without re-walking the same discovery directories** — dogfooded 2026-05-26 on `9757fef8`. `claw agents list --output-format json` returned `{name, description, model, source: {id, label, detail_label: null}}` with no disk path. `AgentSummary` had no `path` field; the `entry.path()` from the `fs::read_dir` loop was discarded after frontmatter parsing. Fix: added `path: Option<PathBuf>` to `AgentSummary`; populated from `entry.path()` in the discovery loop; exposed as `"path": string|null` in `agent_summary_json`. Agents now return e.g. `{path:"/Users/.../.codex/agents/codex-ultrawork-reviewer.toml"}`. Parity gap: `skills list` still lacks `path` — tracked as a follow-on (same fix needed in `SkillSummary`). Source: Jobdori dogfood on `9757fef8`, 2026-05-26.
|
||||
|
||||
729. **`claw skills list/show --output-format json` had no `path` field — parity gap with `agents list` (#728): callers could not determine which on-disk directory backs each skill without re-walking discovery roots** — dogfooded 2026-05-26 on `fa29909f`. `SkillSummary` had no `path` field; both `SkillOrigin::SkillsDir` (returns `entry.path()`) and `SkillOrigin::LegacyCommandsDir` (returns `markdown_path`) push sites discarded the resolved path after parsing. Fix: added `path: Option<PathBuf>` to `SkillSummary`; `SkillsDir` branch populates `Some(entry.path())`, `LegacyCommandsDir` branch populates `Some(markdown_path)`; `skill_summary_json` exposes `"path": string|null`. Skills now return e.g. `{path:"/Users/.../.agents/skills/agent-browser"}`. Completes the path-discoverability trio started in #728 (agents) — plugins path is a remaining follow-on. Source: Jobdori dogfood on `fa29909f`, 2026-05-26.
|
||||
|
||||
730. **`claw plugins list/show --output-format json` had no `path` field — parity gap completing the agents (#728) / skills (#729) trio: callers could not determine which on-disk directory backs each plugin without re-walking discovery roots** — dogfooded 2026-05-26 on `8f44ad30`. `plugin_summary_json` in `rusty-claude-cli/src/main.rs` rendered all `PluginMetadata` fields except `root: Option<PathBuf>`, which was already present in the struct. Fix: added `"path": plugin.metadata.root.as_ref().map(|p| p.display().to_string())` to `plugin_summary_json`. Plugins now return e.g. `{path:"/Users/.../.claw/plugins/installed/example-bundled-bundled"}`. Completes path-discoverability across all three extension surfaces (agents, skills, plugins). Source: Jobdori dogfood on `8f44ad30`, 2026-05-26.
|
||||
|
||||
731. **`claw sandbox --output-format json` returned `status:"error"` when namespace isolation is unsupported on macOS but filesystem sandbox is active — automation treating `status != "ok"` as a hard error would block on a fully-functional degraded sandbox** — dogfooded 2026-05-26 on `425d94ee`. `sandbox_json_value` derived `status:"error"` when `!status.supported` regardless of whether `filesystem_active:true` (workspace-write containment working). On macOS the typical state is `{supported:false, filesystem_active:true, active_namespace:false}` — namespace isolation is unsupported but the filesystem sandbox IS active. This is degradation, not failure. Fix: added `else if status.filesystem_active { "warn" }` branch before the hard `"error"` arm — `status:"error"` is now reserved for the case where sandbox is enabled, unsupported, AND no filesystem containment is active either. macOS default now correctly returns `status:"warn"`. Source: Jobdori dogfood on `425d94ee`, 2026-05-26.
|
||||
|
||||
732. **`claw status --output-format json` `allowed_tools.entries` was `null` when no `--allowed-tools` flag was passed — callers doing `.allowed_tools.entries | length > 0` or trying to iterate got a null-dereference instead of an empty array** — dogfooded 2026-05-26 on `29dcd478`. `allowed_tool_entries` was computed as `allowed_tools.map(|tools| tools.iter().cloned().collect())` — `None` when unrestricted, serialized to JSON `null`. Fix: `.unwrap_or_default()` so unrestricted invocations emit `entries: []` instead of `entries: null`. Callers can now use `.entries | length > 0` uniformly without a null guard. Source: Jobdori dogfood on `29dcd478`, 2026-05-26.
|
||||
|
||||
733. **`claw diff --output-format json` returned no `changed_file_count` field — callers seeing `result:"changes"` had to parse the raw `staged`/`unstaged` diff text to count affected files** — dogfooded 2026-05-26 on `4c16a42f`. `render_diff_json_for` ran `git diff --cached` and `git diff` and exposed them as raw strings but didn't compute a file count. Fix: run two additional `git diff --name-only` passes (staged + unstaged), deduplicate across both sets using a `BTreeSet`, and expose `changed_file_count: usize` in the envelope. Clean repos emit `changed_file_count: 0`, dirty repos emit the true unique-file count. Source: Jobdori dogfood on `4c16a42f`, 2026-05-26.
|
||||
|
||||
734. **`agents show <name>` and `plugins show <name>` error envelopes had no `message` field when the target was not found — `skills show` had `"message": "skill 'X' not found"` but the other two omitted it, leaving callers with only `error_kind` and `requested` and no human-readable explanation in the same field shape** — dogfooded 2026-05-26 on `cc86f54d`. Added `"message": "agent 'X' not found"` to the `agent_not_found` branch in `commands/src/lib.rs` and `"message": "plugin 'X' not found"` to the `plugin_not_found` branch in `rusty-claude-cli/src/main.rs`; both now match the `skills show` shape. Source: Jobdori dogfood on `cc86f54d`, 2026-05-26.
|
||||
|
||||
735. **`claw /compact --output-format json` (and other interactive-only slash commands invoked outside a session) emitted `error_kind:"unknown"` instead of `error_kind:"interactive_only"` — `classify_error_kind` matched `"is a slash command"` and `"interactive_only:"` prefix but missed the `"slash command /X is interactive-only"` sentence pattern emitted by the interactive-only guard; automation branching on `error_kind` got `"unknown"` and couldn't distinguish "you called an interactive command outside a session" from a genuine unknown failure** — dogfooded 2026-05-26 on `d4494a8a`. Added `message.starts_with("slash command") && message.contains("interactive-only")` branch to `classify_error_kind` alongside the existing two matchers. Source: Jobdori dogfood on `d4494a8a`, 2026-05-26.
|
||||
|
||||
736. **`claw doctor --output-format json` `boot_preflight` check `details[]` had `value: null` for `Required binary`, `Last failed boot`, `MCP eligible`, and `Plugin eligible` entries — all four used format strings with no double-space separator, so the prose-splitter that builds `{key, value}` objects (introduced in #701) could not split key from value and emitted the entire string as `key` with `value: null`** — dogfooded 2026-05-26 on `b3242e8c`. Fix: insert the two-space separator between the label and its value in each format string: `"Required binary {} available={}"` → `key="Required binary claw"` / `value="available=true"`; `"Last failed boot {}"` → `key="Last failed boot"` / `value="<none>"`; MCP/Plugin eligible compound values use `" · "` intra-value separator since `splitn(2, " ")` splits only on the first double-space run. Source: Jobdori dogfood on `b3242e8c`, 2026-05-26.
|
||||
|
||||
737. **Test coverage gap: `doctor --output-format json` `boot_preflight` `details[]` had no assertion that entries are `{key,value}` objects with non-null `value` fields — the #736 double-space separator fix had no regression guard, so a revert or accidental prose-format change would silently re-introduce `value:null` entries** — filed 2026-05-26 on `ad982d20`. Added assertions to `doctor_and_resume_status_emit_json_when_requested` in `output_format_contract.rs`: iterate all `boot_preflight.details[]` entries and assert each has a string `key` and a non-null `value`. Source: Jobdori dogfood on `ad982d20`, 2026-05-26.
|
||||
|
||||
738. **`claw /commit --output-format json` (and all other interactive-only slash commands invoked outside a session) emitted `hint: null` — the remediation text was in the `error` prose string but no newline separated the short error from the hint, so `split_error_hint` returned the entire message as `error` and `hint: null`** — dogfooded 2026-05-26 on `c592313d`. The format string `"slash command {cmd} is interactive-only. Start `claw`..."` had no newline, so `split_error_hint` (which splits on `\n`) could not extract the hint. Fix: add `\n` between the short error `"slash command X is interactive-only."` and the remediation text, so callers reading `.hint` get the actionable guidance directly. Source: Jobdori dogfood on `c592313d`, 2026-05-26.
|
||||
|
||||
739. **`claw skills <unknown-subcommand> --output-format json` emitted two JSON objects on stdout: first the usage envelope (`action:"help", unexpected:"X"`), then a second error abort envelope (`kind:"unknown", error:"skills command failed"`) — the `print_skills` JSON path returned `Err` on `status:"error"` responses even when the response was a normal usage-display (`action:"help"`), causing the generic error serializer to emit the second envelope** — dogfooded 2026-05-26 on `4c3cb0f3`. Fix: skip the `return Err` path when `action == "help"`; usage envelopes are informational, not fatal errors. The root prompt-dispatch gap (`claw skills bogus` → `CliAction::Prompt` → `missing_credentials` in no-creds env) is a pre-existing auth-gate-on-local-surface issue (ROADMAP #431/#449) and not addressed here. Source: Jobdori dogfood on `4c3cb0f3`, 2026-05-26.
|
||||
|
||||
740. **Test coverage gap for ROADMAP #733: `diff_json_has_status_and_result_field_702` did not assert `changed_file_count` contract** — dogfooded 2026-05-26 on `d5f0d6ed`. The test asserts `kind`, `status`, `result`, `action`, `working_directory` but not the new `changed_file_count` field added by #733. Coverage gap: (a) no assertion that the field exists, (b) no assertion of numeric type in git repos, (c) no regression guard for dedupe behavior (staged+unstaged to the same file = 1 changed file). Fix: extend the test to assert `changed_file_count: null` in non-git repos and `changed_file_count: u64` in git repos. Source: gaebal-gajae dogfood on `d5f0d6ed`, 2026-05-26.
|
||||
|
||||
@@ -2145,6 +2145,8 @@ struct AgentSummary {
|
||||
reasoning_effort: Option<String>,
|
||||
source: DefinitionSource,
|
||||
shadowed_by: Option<DefinitionSource>,
|
||||
// #728: on-disk path so `agents show` can surface the file path
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -2154,6 +2156,8 @@ struct SkillSummary {
|
||||
source: DefinitionSource,
|
||||
shadowed_by: Option<DefinitionSource>,
|
||||
origin: SkillOrigin,
|
||||
// #729: on-disk path parity with AgentSummary
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -2455,6 +2459,8 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
"status": "error",
|
||||
"error_kind": "agent_not_found",
|
||||
"requested": name,
|
||||
// #734: parity with skills show which always emits a message field
|
||||
"message": format!("agent '{}' not found", name),
|
||||
}));
|
||||
}
|
||||
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
|
||||
@@ -3541,6 +3547,7 @@ fn load_agents_from_roots(
|
||||
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
|
||||
source: *source,
|
||||
shadowed_by: None,
|
||||
path: Some(entry.path()),
|
||||
});
|
||||
}
|
||||
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
@@ -3585,6 +3592,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
||||
source: root.source,
|
||||
shadowed_by: None,
|
||||
origin: root.origin,
|
||||
path: Some(entry.path()),
|
||||
});
|
||||
}
|
||||
SkillOrigin::LegacyCommandsDir => {
|
||||
@@ -3616,6 +3624,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
||||
source: root.source,
|
||||
shadowed_by: None,
|
||||
origin: root.origin,
|
||||
path: Some(markdown_path),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4273,6 +4282,8 @@ fn agent_summary_json(agent: &AgentSummary) -> Value {
|
||||
"source": definition_source_json(agent.source),
|
||||
"active": agent.shadowed_by.is_none(),
|
||||
"shadowed_by": agent.shadowed_by.map(definition_source_json),
|
||||
// #728: expose on-disk path so callers can inspect the agent file directly
|
||||
"path": agent.path.as_ref().map(|p| p.display().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4298,6 +4309,8 @@ fn skill_summary_json(skill: &SkillSummary) -> Value {
|
||||
"origin": skill_origin_json(skill.origin),
|
||||
"active": skill.shadowed_by.is_none(),
|
||||
"shadowed_by": skill.shadowed_by.map(definition_source_json),
|
||||
// #729: path parity with agent_summary_json
|
||||
"path": skill.path.as_ref().map(|p| p.display().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,8 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
"session_load_failed"
|
||||
} else if message.contains("no managed sessions found") {
|
||||
"no_managed_sessions"
|
||||
} else if message.contains("legacy session is missing workspace binding") {
|
||||
"legacy_session_no_workspace_binding"
|
||||
} else if message.contains("unsupported ACP invocation") {
|
||||
"unsupported_acp_invocation"
|
||||
} else if message.contains("unsupported skills action") {
|
||||
@@ -314,7 +316,11 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
"unsupported_config_section"
|
||||
} else if message.contains("unknown_plugins_action") {
|
||||
"unknown_plugins_action"
|
||||
} else if message.contains("is a slash command") || message.starts_with("interactive_only:") {
|
||||
} else if message.contains("is a slash command")
|
||||
|| message.starts_with("interactive_only:")
|
||||
// #735: "slash command /X is interactive-only" emitted by interactive-only guard
|
||||
|| (message.starts_with("slash command") && message.contains("interactive-only"))
|
||||
{
|
||||
"interactive_only"
|
||||
} else {
|
||||
"unknown"
|
||||
@@ -400,6 +406,8 @@ fn plugin_summary_json(plugin: &plugins::PluginSummary) -> Value {
|
||||
"description": &plugin.metadata.description,
|
||||
"kind": plugin.metadata.kind.to_string(),
|
||||
"source": &plugin.metadata.source,
|
||||
// #730: path parity with agents (#728) and skills (#729)
|
||||
"path": plugin.metadata.root.as_ref().map(|p| p.display().to_string()),
|
||||
"enabled": plugin.enabled,
|
||||
"lifecycle_state": plugin.lifecycle_state(),
|
||||
"lifecycle": {
|
||||
@@ -1505,7 +1513,8 @@ fn parse_direct_slash_cli_action(
|
||||
Ok(Some(command)) => Err({
|
||||
let _ = command;
|
||||
format!(
|
||||
"slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.",
|
||||
// #738: newline before remediation so split_error_hint populates hint field
|
||||
"slash command {command_name} is interactive-only.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.",
|
||||
command_name = rest[0],
|
||||
latest = LATEST_SESSION_REFERENCE,
|
||||
)
|
||||
@@ -2120,7 +2129,11 @@ impl DiagnosticCheck {
|
||||
),
|
||||
("summary".to_string(), Value::String(self.summary.clone())),
|
||||
(
|
||||
"details".to_string(),
|
||||
// #701 (complete): `details[]` is now the canonical structured form —
|
||||
// `{key, value}` objects instead of padded prose strings. The legacy
|
||||
// prose representation is preserved as `details_prose[]` for callers
|
||||
// that still scrape the formatted strings.
|
||||
"details_prose".to_string(),
|
||||
Value::Array(
|
||||
self.details
|
||||
.iter()
|
||||
@@ -2130,10 +2143,8 @@ impl DiagnosticCheck {
|
||||
),
|
||||
),
|
||||
(
|
||||
// #701: structured key/value pairs parsed from prose detail strings.
|
||||
// Each detail string is `"Key Label value"` separated by 2+ spaces.
|
||||
// Booleans (`true`/`false`) and integers are emitted as JSON scalars.
|
||||
"detail_entries".to_string(),
|
||||
// details[] is now structured {key,value} objects (was prose strings).
|
||||
"details".to_string(),
|
||||
Value::Array(
|
||||
self.details
|
||||
.iter()
|
||||
@@ -2750,16 +2761,26 @@ fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
.map_or("unknown".to_string(), |v| v.to_string())
|
||||
),
|
||||
format!("Trusted roots {}", preflight.trusted_roots_count),
|
||||
// #736: keep compound values readable but use " · " as intra-value separator
|
||||
// so the two-space prose splitter yields key="MCP eligible" value="true · servers 0"
|
||||
format!(
|
||||
"MCP eligible {} · servers {}",
|
||||
preflight.mcp_startup_eligible, preflight.mcp_servers_configured
|
||||
"MCP eligible {}",
|
||||
format!(
|
||||
"{} · servers {}",
|
||||
preflight.mcp_startup_eligible, preflight.mcp_servers_configured
|
||||
)
|
||||
),
|
||||
format!(
|
||||
"Plugin eligible {} · configured {}",
|
||||
preflight.plugin_startup_eligible, preflight.plugins_configured
|
||||
"Plugin eligible {}",
|
||||
format!(
|
||||
"{} · configured {}",
|
||||
preflight.plugin_startup_eligible, preflight.plugins_configured
|
||||
)
|
||||
),
|
||||
format!(
|
||||
"Last failed boot {}",
|
||||
// #736: use two-space separator so the detail_entries prose splitter
|
||||
// can extract key="Last failed boot" value="<none>|<reason>"
|
||||
"Last failed boot {}",
|
||||
preflight
|
||||
.last_failed_boot_reason
|
||||
.as_deref()
|
||||
@@ -2768,7 +2789,8 @@ fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
];
|
||||
details.extend(preflight.required_binaries.iter().map(|binary| {
|
||||
format!(
|
||||
"Required binary {} available={}",
|
||||
// #736: two-space separator → key="Required binary <name>" value="available=true|false"
|
||||
"Required binary {} available={}",
|
||||
binary.name, binary.available
|
||||
)
|
||||
}));
|
||||
@@ -3324,6 +3346,10 @@ impl BranchFreshness {
|
||||
fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"upstream": self.upstream,
|
||||
// #727: has_upstream disambiguates fresh:null-because-no-upstream
|
||||
// from fresh:null-because-unavailable; automation should check
|
||||
// has_upstream before branching on fresh.
|
||||
"has_upstream": self.upstream.is_some(),
|
||||
"ahead": self.ahead,
|
||||
"behind": self.behind,
|
||||
"fresh": self.fresh,
|
||||
@@ -6054,8 +6080,11 @@ impl LiveCli {
|
||||
CliOutputFormat::Json => {
|
||||
let result = handle_skills_slash_command_json(args, &cwd)?;
|
||||
let is_error = result.get("status").and_then(|v| v.as_str()) == Some("error");
|
||||
// #739: action:"help" with unexpected set is a usage response, not a fatal error;
|
||||
// don't return Err which would emit a second error envelope from the generic path.
|
||||
let is_help_action = result.get("action").and_then(|v| v.as_str()) == Some("help");
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
if is_error {
|
||||
if is_error && !is_help_action {
|
||||
return Err(result
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -6130,6 +6159,8 @@ impl LiveCli {
|
||||
"status": "error",
|
||||
"error_kind": "plugin_not_found",
|
||||
"requested": name,
|
||||
// #734: parity with skills show which always emits a message field
|
||||
"message": format!("plugin '{}' not found", name),
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&obj)?);
|
||||
return Ok(());
|
||||
@@ -6921,7 +6952,11 @@ fn status_json_value(
|
||||
let degraded = context.config_load_error.is_some();
|
||||
let model_source = provenance.map(|p| p.source.as_str());
|
||||
let model_raw = provenance.and_then(|p| p.raw.clone());
|
||||
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
||||
// #732: always emit an array (empty when unrestricted) so callers can do
|
||||
// `.allowed_tools.entries | length > 0` without a null-check first.
|
||||
let allowed_tool_entries = allowed_tools
|
||||
.map(|tools| tools.iter().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"kind": "status",
|
||||
"action": "show",
|
||||
@@ -7268,15 +7303,22 @@ fn print_sandbox_status_snapshot(
|
||||
fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||
// Derive top-level status so automation can do a single field check
|
||||
// instead of combining enabled/active/supported booleans.
|
||||
// ok = not enabled (not requested), OR enabled and active
|
||||
// warn = enabled and supported but not yet active (degraded)
|
||||
// error = enabled but unsupported on this platform
|
||||
// ok = not enabled (not requested), OR enabled and active
|
||||
// warn = enabled and supported but not yet active (degraded),
|
||||
// OR enabled but unsupported on this platform AND filesystem sandbox is active
|
||||
// (#731: "not supported on macOS" is a degraded state, not a hard error;
|
||||
// filesystem_active:true means partial containment is working)
|
||||
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
|
||||
let top_status = if !status.enabled {
|
||||
"ok"
|
||||
} else if status.active {
|
||||
"ok"
|
||||
} else if status.supported {
|
||||
"warn"
|
||||
} else if status.filesystem_active {
|
||||
// Platform doesn't support namespace isolation but filesystem sandbox is active:
|
||||
// this is a degraded/partial state, not a hard error.
|
||||
"warn"
|
||||
} else {
|
||||
"error"
|
||||
};
|
||||
@@ -7954,12 +7996,25 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
|
||||
}
|
||||
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||
// #733: add changed_file_count so callers don't have to count diff hunks
|
||||
let staged_files =
|
||||
run_git_diff_command_in(cwd, &["diff", "--cached", "--name-only"]).unwrap_or_default();
|
||||
let unstaged_files = run_git_diff_command_in(cwd, &["diff", "--name-only"]).unwrap_or_default();
|
||||
let mut changed: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
|
||||
for line in staged_files.lines().chain(unstaged_files.lines()) {
|
||||
let t = line.trim();
|
||||
if !t.is_empty() {
|
||||
changed.insert(t);
|
||||
}
|
||||
}
|
||||
let changed_file_count = changed.len();
|
||||
Ok(serde_json::json!({
|
||||
"kind": "diff",
|
||||
"action": "diff",
|
||||
"status": "ok",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
|
||||
"changed_file_count": changed_file_count,
|
||||
"staged": staged.trim(),
|
||||
"unstaged": unstaged.trim(),
|
||||
}))
|
||||
|
||||
@@ -663,6 +663,22 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(boot_preflight["boot_preflight"]["repo"]["exists"].is_boolean());
|
||||
assert!(boot_preflight["boot_preflight"]["mcp_startup"]["eligible"].is_boolean());
|
||||
assert!(boot_preflight["boot_preflight"]["required_binaries"].is_array());
|
||||
// #736: details[] must be {key,value} objects with non-null values;
|
||||
// regression guard for the double-space separator fix on boot_preflight prose strings.
|
||||
let bp_details = boot_preflight["details"]
|
||||
.as_array()
|
||||
.expect("boot_preflight details must be array");
|
||||
for entry in bp_details {
|
||||
assert!(
|
||||
entry["key"].is_string(),
|
||||
"boot_preflight detail entry missing string key: {entry:?}"
|
||||
);
|
||||
assert!(
|
||||
!entry["value"].is_null(),
|
||||
"boot_preflight detail entry has null value (prose-splitter failed): key={:?}",
|
||||
entry["key"]
|
||||
);
|
||||
}
|
||||
|
||||
let sandbox = checks
|
||||
.iter()
|
||||
@@ -1340,6 +1356,24 @@ fn diff_json_has_status_and_result_field_702() {
|
||||
.is_some(),
|
||||
"diff JSON must have working_directory field (#710)"
|
||||
);
|
||||
// #740: diff JSON changed_file_count contract: numeric in git repos, absent for no_git_repo
|
||||
let result_str = parsed.get("result").and_then(|v| v.as_str());
|
||||
if result_str == Some("no_git_repo") {
|
||||
// Non-git repos don't emit changed_file_count
|
||||
assert!(
|
||||
parsed.get("changed_file_count").is_none(),
|
||||
"diff JSON should not have changed_file_count for no_git_repo (#733)"
|
||||
);
|
||||
} else {
|
||||
// Git repos must emit numeric changed_file_count
|
||||
assert!(
|
||||
parsed
|
||||
.get("changed_file_count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some(),
|
||||
"diff JSON changed_file_count must be numeric in git repos (#733)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,24 +10,47 @@
|
||||
# ${NEXT}. **...description...**
|
||||
# EOF
|
||||
#
|
||||
# The script reads the highest numeric id prefix from ROADMAP.md and
|
||||
# prints highest+1. It does not lock the file; callers working in
|
||||
# parallel should git-pull immediately before appending, run
|
||||
# scripts/roadmap-check-ids.sh before push, and resolve any append
|
||||
# collision at git-push time.
|
||||
# The script first validates helper-era ids with roadmap-check-ids.sh, then
|
||||
# reads the highest numeric id prefix from ROADMAP.md and prints highest+1. It
|
||||
# does not lock the file; callers working in parallel should git-pull
|
||||
# immediately before appending, run scripts/roadmap-check-ids.sh before push,
|
||||
# and resolve any append collision at git-push time.
|
||||
set -euo pipefail
|
||||
|
||||
ROADMAP="${1:-ROADMAP.md}"
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
CHECKER="$SCRIPT_DIR/roadmap-check-ids.sh"
|
||||
|
||||
if [[ ! -f "$ROADMAP" ]]; then
|
||||
echo "error: ROADMAP not found at $ROADMAP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the highest leading integer from lines that start with a number + '.'.
|
||||
highest=$(grep -E '^[0-9]+\.' "$ROADMAP" | grep -Eo '^[0-9]+' | sort -n | tail -1)
|
||||
if [[ ! -f "$CHECKER" || ! -r "$CHECKER" ]]; then
|
||||
echo "error: required ROADMAP id checker not found or not readable at $CHECKER" >&2
|
||||
echo "error: refusing to print a next id without duplicate-id validation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$highest" ]]; then
|
||||
if ! checker_output="$(bash "$CHECKER" "$ROADMAP" 2>&1)"; then
|
||||
printf '%s\n' "$checker_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the highest leading integer from lines that start with a number + '.'.
|
||||
highest=$(awk '
|
||||
/^[0-9]+\./ {
|
||||
id = $0
|
||||
sub(/\..*/, "", id)
|
||||
id += 0
|
||||
if (id > highest) {
|
||||
highest = id
|
||||
}
|
||||
}
|
||||
END { print highest + 0 }
|
||||
' "$ROADMAP")
|
||||
|
||||
if [[ "$highest" -eq 0 ]]; then
|
||||
echo 1
|
||||
else
|
||||
echo $(( highest + 1 ))
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
|
||||
|
||||
|
||||
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
['bash', str(script), str(roadmap)],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class RoadmapHelperTests(unittest.TestCase):
|
||||
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('721. old\n723. helper era\n724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertEqual('725\n', result.stdout)
|
||||
self.assertEqual('', result.stderr)
|
||||
|
||||
def test_roadmap_next_id_fails_fast_on_helper_era_duplicate(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('722. legacy\n999. first\n999. duplicate\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('duplicate ROADMAP numeric id(s)', result.stderr)
|
||||
self.assertIn('999', result.stderr)
|
||||
self.assertNotIn('1000', result.stdout)
|
||||
|
||||
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
script_dir = Path(temp_dir) / 'scripts'
|
||||
script_dir.mkdir()
|
||||
copied_next_id = script_dir / 'roadmap-next-id.sh'
|
||||
shutil.copy2(NEXT_ID, copied_next_id)
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap, copied_next_id)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
|
||||
self.assertIn('refusing to print a next id', result.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user