Compare commits

...

17 Commits

Author SHA1 Message Date
YeonGyu-Kim 5d072d21e9 test(#740): diff JSON contract test now asserts changed_file_count field behavior per #733 2026-05-26 16:45:02 +09:00
YeonGyu-Kim d5f0d6ed3e fix(#739): skills unknown-subcommand JSON path no longer emits double error envelope; help action not propagated as Err 2026-05-26 16:38:17 +09:00
YeonGyu-Kim 4c3cb0f347 fix(#738): interactive-only slash command error adds newline before hint; hint field now non-null with remediation text 2026-05-26 16:06:38 +09:00
YeonGyu-Kim c592313d9a test(#737): add boot_preflight details non-null-value regression guard to output_format_contract 2026-05-26 15:05:00 +09:00
YeonGyu-Kim ad982d20c2 fix(#736): boot_preflight doctor details[] null-value entries: add double-space separator to Required binary, Last failed boot, MCP/Plugin eligible format strings 2026-05-26 14:33:18 +09:00
YeonGyu-Kim b3242e8c04 fix(#735): classify_error_kind: /compact and other interactive-only slash commands now emit error_kind:interactive_only not unknown 2026-05-26 14:08:53 +09:00
YeonGyu-Kim d4494a8aeb fix(#734): agents/plugins show not-found envelopes gain message field; parity with skills show 2026-05-26 13:34:36 +09:00
YeonGyu-Kim cc86f54d65 fix(#701): doctor JSON details[] now {key,value} objects; prose preserved as details_prose[]; acceptance check passes 2026-05-26 13:10:05 +09:00
YeonGyu-Kim db80c9b96e fix(#733): diff JSON adds changed_file_count; run git diff --name-only for staged+unstaged and deduplicate into BTreeSet 2026-05-26 13:05:44 +09:00
YeonGyu-Kim 4c16a42f39 fix(#732): status JSON allowed_tools.entries:null→[] when unrestricted; callers can use .entries|length without null guard 2026-05-26 12:36:13 +09:00
YeonGyu-Kim 29dcd478a0 fix(#731): sandbox JSON status:error→warn when filesystem sandbox active but namespace unsupported (macOS degraded state) 2026-05-26 12:05:11 +09:00
YeonGyu-Kim 425d94ee43 fix(#730): add path field to plugins list/show JSON; completes path-discoverability trio (agents #728, skills #729, plugins #730) 2026-05-26 11:38:48 +09:00
YeonGyu-Kim 8f44ad308d fix(#729): add path field to skills list/show JSON; SkillSummary parity with AgentSummary (#728) 2026-05-26 11:32:53 +09:00
YeonGyu-Kim fa29909f05 fix(#728): add path field to agents list/show JSON; AgentSummary now stores on-disk .toml path from discovery loop 2026-05-26 11:09:46 +09:00
YeonGyu-Kim 9757fef8a7 fix(#727): add has_upstream bool to branch_freshness JSON to disambiguate fresh:null-no-upstream from fresh:null-unknown 2026-05-26 10:34:28 +09:00
YeonGyu-Kim a0c6c8ba53 fix(#726): classify legacy_session_no_workspace_binding error_kind in export path 2026-05-26 10:04:32 +09:00
Bellman 49d5b3fcdc Prevent poisoned ROADMAP ids before allocation (#3116)
Constraint: roadmap-next-id.sh must preserve single-id stdout on success while failing closed if duplicate validation cannot run.
Rejected: Relying only on CI/pre-push duplicate checks | the helper is used immediately before appending and must not certify an already-poisoned file.
Confidence: high
Scope-risk: narrow
Directive: Keep roadmap-next-id.sh stdout machine-clean; route validation failures and checker availability errors to stderr, and keep focused helper behavior coverage in the docs/ROADMAP CI path.
Tested: scripts/roadmap-next-id.sh ROADMAP.md printed 725 before appending #725 and 726 after; temp ROADMAP with duplicate 999 exited nonzero and listed duplicate id; scripts/roadmap-check-ids.sh ROADMAP.md; bash -n scripts/roadmap-next-id.sh scripts/roadmap-check-ids.sh; python -m unittest discover -s tests -p test_roadmap_helpers.py; python -m pytest tests/test_roadmap_helpers.py -q; SKIP_CLAW_PRE_PUSH_BUILD=1 bash .github/hooks/pre-push
Not-tested: full cargo workspace build, unchanged docs/script-only path
2026-05-26 09:10:02 +09:00
7 changed files with 254 additions and 26 deletions
+4
View File
@@ -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
View File
@@ -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.
+13
View File
@@ -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()),
})
}
+73 -18
View File
@@ -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]
+31 -8
View File
@@ -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 ))
+67
View File
@@ -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()