diff --git a/changes/jfgy-neutral-parents-notice.md b/changes/jfgy-neutral-parents-notice.md new file mode 100644 index 0000000..c3c52e7 --- /dev/null +++ b/changes/jfgy-neutral-parents-notice.md @@ -0,0 +1,15 @@ +--- +type: changed +facade: changed +--- +[en] +`nxf show` now points at an item's parents in a neutral line — `Parents: (read for full context)` — +instead of "URGENT RECOMMENDATION: … !!!". Agents reading the board had flagged the old line as a +possible prompt injection. The `parents_notice` field in `--json` and on the facade carries the same +new text; its name and when it appears are unchanged. +[de] +`nxf show` verweist auf die Eltern eines Eintrags jetzt in einer neutralen Zeile — +`Parents: (read for full context)` — statt mit „URGENT RECOMMENDATION: … !!!“. Agenten, die das +Board lesen, hatten die alte Zeile als möglichen Prompt-Injection-Versuch gemeldet. Das Feld +`parents_notice` in `--json` und in der Fassade trägt denselben neuen Text; Name und Bedingung, wann es +erscheint, bleiben gleich. diff --git a/crates/cli/docs/guide/de/commands.md b/crates/cli/docs/guide/de/commands.md index 28f3d7d..04d2340 100644 --- a/crates/cli/docs/guide/de/commands.md +++ b/crates/cli/docs/guide/de/commands.md @@ -43,7 +43,7 @@ NOTES - 2026-06-23 started on the command layer -This item has the following parents: ab12.0001. URGENT RECOMMENDATION: ALSO READ THESE ITEMS TO GET THE COMPLETE PICTURE!!! +Parents: ab12.0001 (read for full context) ``` `list` gibt jedes Item aus; mit `--json` ist es der kanonische, byte-stabile Schnappschuss diff --git a/crates/cli/docs/guide/en/commands.md b/crates/cli/docs/guide/en/commands.md index 7553123..5cce6db 100644 --- a/crates/cli/docs/guide/en/commands.md +++ b/crates/cli/docs/guide/en/commands.md @@ -41,7 +41,7 @@ NOTES - 2026-06-23 started on the command layer -This item has the following parents: ab12.0001. URGENT RECOMMENDATION: ALSO READ THESE ITEMS TO GET THE COMPLETE PICTURE!!! +Parents: ab12.0001 (read for full context) ``` `list` dumps every item; with `--json` it is the canonical, byte-stable snapshot (id-ordered, diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index e302f3c..6bef09e 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2268,8 +2268,8 @@ pub fn show(json: bool, db: Option<&str>, id: &str) -> Result<()> { /// they apply), then the DESCRIPTION / DEFINITION OF DONE / DESIGN / NOTES sections whose /// headings are the plugin's field labels. `--json` carries everything for machines; this is /// the human surface, so due/defer/assignee live in the record but not in this layout. -/// `parents_notice` (6j6v.zvd0) — the engine-computed parent-pointer notice — is rendered -/// verbatim as the very last thing when the item has parent(s); omitted entirely otherwise. +/// `parents_notice` (6j6v.zvd0) — the engine-computed parent-pointer notice — is printed +/// unchanged as the very last thing when the item has parent(s); omitted entirely otherwise. #[allow(clippy::too_many_arguments)] fn render_show( cfg: &PluginConfig, @@ -2417,12 +2417,11 @@ fn render_show( } } - // Parent-pointer notice (6j6v.zvd0): the engine's verbatim, loud reminder to also read the - // parent(s) — appended last, only when the item actually has parent(s). Deliberately restates - // the quiet `PARENT:` line above for a single parent (Code Quality #2, PR #255 review): the - // `PARENT:` field is easy to skim past, and for MULTIPLE parents it shows only the LWW - // `belongs_to` winner — this notice is the one place that is both unmissable and lists every - // live parent. + // Parent-pointer notice (6j6v.zvd0, worded neutrally since 6j6v.jfgy): the engine's pointer to + // the parent(s) — appended last, only when the item actually has parent(s). Deliberately + // restates the `PARENT:` line above for a single parent (Code Quality #2, PR #255 review): for + // MULTIPLE parents that field shows only the LWW `belongs_to` winner — this notice is the one + // place that lists every live parent. if let Some(notice) = parents_notice { out.push('\n'); out.push_str(notice); diff --git a/crates/cli/tests/golden/issue-tracker/workflow.trycmd b/crates/cli/tests/golden/issue-tracker/workflow.trycmd index a5af3d6..3d8a9f3 100644 --- a/crates/cli/tests/golden/issue-tracker/workflow.trycmd +++ b/crates/cli/tests/golden/issue-tracker/workflow.trycmd @@ -213,7 +213,7 @@ NOTES - 2026-06-23 started on the command layer -This item has the following parents: ab12.0001. URGENT RECOMMENDATION: ALSO READ THESE ITEMS TO GET THE COMPLETE PICTURE!!! +Parents: ab12.0001 (read for full context) ``` diff --git a/crates/cli/tests/golden/personal-todo/workflow.trycmd b/crates/cli/tests/golden/personal-todo/workflow.trycmd index 3188a8f..4a08c2c 100644 --- a/crates/cli/tests/golden/personal-todo/workflow.trycmd +++ b/crates/cli/tests/golden/personal-todo/workflow.trycmd @@ -212,7 +212,7 @@ LOG - 2026-06-23 checked two airlines -This item has the following parents: ab12.0001. URGENT RECOMMENDATION: ALSO READ THESE ITEMS TO GET THE COMPLETE PICTURE!!! +Parents: ab12.0001 (read for full context) ``` diff --git a/crates/cli/tests/show.rs b/crates/cli/tests/show.rs index c232111..6b24007 100644 --- a/crates/cli/tests/show.rs +++ b/crates/cli/tests/show.rs @@ -7,6 +7,12 @@ use assert_cmd::Command; use std::path::Path; use tempfile::TempDir; +/// How the parent notice opens its line (6j6v.jfgy). The positive test below asserts it is PRESENT, +/// and the two negative tests assert it is ABSENT — one constant for both, so a rewording that +/// moves the lead turns the positive test red instead of leaving the negative ones checking for a +/// string nobody prints any more. +const PARENTS_NOTICE_LEAD: &str = "\nParents: "; + fn nxf() -> Command { nxs_test_support::cargo_bin("nxf") } @@ -207,8 +213,8 @@ fn section_headings_use_the_plugins_field_labels() { #[test] fn appends_parent_notice_at_the_very_end_when_parents_exist() { - // 6j6v.zvd0: an item with a parent gets a loud, verbatim notice appended at the very END of - // `nxf show`'s human output — after NOTES — pointing the reader at the parent(s). + // 6j6v.zvd0: an item with a parent gets a notice appended at the very END of `nxf show`'s + // human output — after NOTES — pointing the reader at the parent(s). Neutral since 6j6v.jfgy. let tmp = TempDir::new().unwrap(); init(tmp.path(), "issue-tracker"); let parent = create(tmp.path(), "epic", "Epic", &[]); @@ -220,10 +226,11 @@ fn appends_parent_notice_at_the_very_end_when_parents_exist() { .success(); let out = show(tmp.path(), &task); - let expected = format!( - "This item has the following parents: {parent}. URGENT RECOMMENDATION: ALSO READ THESE \ - ITEMS TO GET THE COMPLETE PICTURE!!!" + assert!( + out.contains(PARENTS_NOTICE_LEAD), + "the notice opens its own line with the shared lead:\n{out}" ); + let expected = format!("Parents: {parent} (read for full context)"); assert!( out.trim_end().ends_with(expected.trim_end()), "notice must be the very last thing in the output:\n{out}" @@ -237,7 +244,7 @@ fn omits_parent_notice_when_no_parent() { let task = create(tmp.path(), "bug", "Standalone", &[]); let out = show(tmp.path(), &task); assert!( - !out.contains("URGENT RECOMMENDATION"), + !out.contains(PARENTS_NOTICE_LEAD), "no parent notice when there is no parent:\n{out}" ); } @@ -258,7 +265,7 @@ fn omits_parent_notice_for_a_contributes_to_only_edge() { let out = show(tmp.path(), &cream); assert!( - !out.contains("URGENT RECOMMENDATION"), + !out.contains(PARENTS_NOTICE_LEAD), "a contributes_to edge is not a parent, so no notice:\n{out}" ); } diff --git a/crates/facade/src/read.rs b/crates/facade/src/read.rs index 07046cc..5cbe0a6 100644 --- a/crates/facade/src/read.rs +++ b/crates/facade/src/read.rs @@ -1040,17 +1040,21 @@ fn live_parents_of(store: &Store, item_id: &str) -> Result> { Ok(live) } -/// The loud, verbatim notice urging the reader to also read `parents` (6j6v.zvd0): `None` when -/// `parents` is empty, so a parentless item carries no notice at all. Lives here — not in the CLI -/// printer — so `nxf show` (human), `--json`, and every other consumer (MCP, embedders) render the -/// identical text off the identical engine computation. +/// The notice pointing the reader at `parents` (6j6v.zvd0): `None` when `parents` is empty, so a +/// parentless item carries no notice at all. Lives here — not in the CLI printer — so `nxf show` +/// (human), `--json`, and every other consumer (MCP, embedders) render the identical text off the +/// identical engine computation. +/// +/// Neutral on purpose (6j6v.jfgy, superseding zvd0's "URGENT RECOMMENDATION … !!!"): agents read +/// this output, and a line that sounds like an order got flagged as a prompt injection in the +/// ticket data — or taught agents to skim the urgent line at the end, which blunts them against a +/// real one. It states where the context is; it does not command. fn parents_notice(parents: &[String]) -> Option { if parents.is_empty() { return None; } Some(format!( - "This item has the following parents: {}. URGENT RECOMMENDATION: ALSO READ THESE \ - ITEMS TO GET THE COMPLETE PICTURE!!!", + "Parents: {} (read for full context)", parents.join(", ") )) } @@ -1894,8 +1898,8 @@ pub struct ShowRecord { /// The item's belongs-to/containment parent ids, sorted (6j6v.zvd0); empty when it has none. /// `contributes_to` edges are NOT parents here — see [`parents_notice`] for the derived notice. pub parents: Vec, - /// The verbatim parent-pointer notice (6j6v.zvd0) when [`parents`](Self::parents) is - /// non-empty; `None` for a parentless item. + /// The engine's parent-pointer notice (6j6v.zvd0, neutral since 6j6v.jfgy) when + /// [`parents`](Self::parents) is non-empty; `None` for a parentless item. pub parents_notice: Option, } diff --git a/crates/facade/tests/read.rs b/crates/facade/tests/read.rs index be187e4..5c7002b 100644 --- a/crates/facade/tests/read.rs +++ b/crates/facade/tests/read.rs @@ -453,18 +453,47 @@ fn show_of_an_item_with_one_parent_carries_parents_and_notice() { assert_eq!(rec.parents, vec!["ab12.0001".to_string()]); assert_eq!( rec.parents_notice.as_deref(), - Some( - "This item has the following parents: ab12.0001. URGENT RECOMMENDATION: ALSO READ \ - THESE ITEMS TO GET THE COMPLETE PICTURE!!!" - ) + Some("Parents: ab12.0001 (read for full context)") ); let v = rec.to_value(); assert_eq!(v["parents"], serde_json::json!(["ab12.0001"])); assert_eq!( v["parents_notice"], - "This item has the following parents: ab12.0001. URGENT RECOMMENDATION: ALSO READ \ - THESE ITEMS TO GET THE COMPLETE PICTURE!!!" + "Parents: ab12.0001 (read for full context)" + ); +} + +#[test] +fn the_parents_notice_reads_as_data_not_as_an_instruction() { + // 6j6v.jfgy: the old notice shouted ("URGENT RECOMMENDATION: … !!!"), and agents downstream + // flagged it as a prompt injection in the ticket data — or learned to skim the urgent line at + // the end of board output, which blunts them against real injections. Tool output in an agent + // chain must not sound like an order. Pinned as two properties, so a future rewording keeps + // them without re-deriving them: it does not shout (no exclamation mark, no word in capitals), + // and it is one statement that LEADS with the data — `Parents: ` — with no second sentence + // after the ids where an imperative could sit ("Read these first: …", "Parents: x. Read them."). + let mut s = Store::open_in_memory(1); + open_task(&mut s, "ab12.0001", "Epic", "0"); + open_task(&mut s, "ab12.0002", "Child", "0"); + s.add_parent("ab12.0002", "ab12.0001", "t"); + + let notice = read::show(&s, "ab12.0002").unwrap().parents_notice.unwrap(); + assert!(!notice.contains('!'), "no exclamation mark: {notice}"); + let shouted: Vec<&str> = notice + .split(|c: char| !c.is_ascii_alphabetic()) + .filter(|w| w.len() > 1 && w.chars().all(|c| c.is_ascii_uppercase())) + .collect(); + assert!( + shouted.is_empty(), + "no word in capitals, found {shouted:?}: {notice}" + ); + let after_ids = notice + .strip_prefix("Parents: ab12.0001") + .unwrap_or_else(|| panic!("the notice leads with the data, `Parents: `: {notice}")); + assert!( + !after_ids.contains(['.', ':', '!', '?']), + "one statement: no second sentence after the ids, found {after_ids:?}" ); } @@ -484,10 +513,7 @@ fn show_of_an_item_with_multiple_parents_lists_all_ids_comma_separated() { ); assert_eq!( rec.parents_notice.as_deref(), - Some( - "This item has the following parents: ab12.0001, ab12.0002. URGENT \ - RECOMMENDATION: ALSO READ THESE ITEMS TO GET THE COMPLETE PICTURE!!!" - ) + Some("Parents: ab12.0001, ab12.0002 (read for full context)") ); } @@ -537,10 +563,7 @@ fn show_omits_a_deleted_parent_from_parents_and_notice() { ); assert_eq!( rec.parents_notice.as_deref(), - Some( - "This item has the following parents: ab12.0002. URGENT RECOMMENDATION: ALSO READ \ - THESE ITEMS TO GET THE COMPLETE PICTURE!!!" - ) + Some("Parents: ab12.0002 (read for full context)") ); } diff --git a/xtask/sweep-retired-vocabulary.sh b/xtask/sweep-retired-vocabulary.sh index 8b723cf..9b8f2fb 100755 --- a/xtask/sweep-retired-vocabulary.sh +++ b/xtask/sweep-retired-vocabulary.sh @@ -939,6 +939,17 @@ VOCABULARY=( 'of a fully withdrawn round' # 7me0's rule, in prose # NOT listable, and the review of PR #483 found it by hand: a verb that STAYS but may no longer # be offered to an agent. `git grep 'nxc withdraw' -- crates/chat/src` is that sweep. + # + # nxf 6j6v.jfgy (2026-10-03) — `parents_notice` no longer shouts. zvd0's verbatim "This item has + # the following parents: … URGENT RECOMMENDATION: ALSO READ THESE ITEMS TO GET THE COMPLETE + # PICTURE!!!" became "Parents: (read for full context)": agents downstream flagged the old + # line as a prompt injection. Each phrase is its own term, because a stray copy in prose or a + # golden may carry only part of it. The field NAME stays and is not listed. Expected hits: the + # jfgy changelog fragment and the comments that name the old text as superseded. + 'URGENT RECOMMENDATION' + 'ALSO READ THESE ITEMS' + 'COMPLETE PICTURE' + 'has the following parents' ) # ---- THE EXCLUSIONS, each with the reason it is not a defect ----------------------------------