Skip to content

Commit 263f58c

Browse files
montfortclaude
andauthored
fix: Phase 2 patches part 2 — F1 (slug truncation), F8 (closed_at), drift wildcard glob (#84)
Second round of patches surfaced by Sentinel CHARTER-02..05 telemetry (issue #81 update). After fw-4.6.1/cli-3.7.1 fixed F3/F4/F6, executing four more Charters re-prioritized the remaining frictions: F1 reproduced 4/4× consecutively (was UX polish, now top reproducibility), F8 required manual workaround on every Charter close (4× consecutive), wildcard glob in drift script was a new finding from CHARTER-04 that any future bulk Charter would hit. O1 ("always in scope" rule) validated empirically as a feature in CHARTER-04 (caught real scope expansion). F1 (CLI, charter new): - slugify() now truncates at the last `-` boundary at-or-before the 50-char limit, never producing mid-word fragments. Conservative — when the next char in the original is `-` (or end), the truncated view is already at a word boundary and is kept verbatim. - New --slug flag for explicit override (CHARTER-05 case where a long title's meaningful suffix gets dropped). Override is normalized through the same slugifier so it cannot smuggle in characters that break the filename. F8 (CLI, charter close): - charter close now writes `closed_at: <today>` to the frontmatter alongside `status: closed` bump. If the Charter already had a closed_at (e.g. prior close that was reverted), the value is refreshed to today rather than left stale. Drift wildcard glob (framework, check-charter-drift.sh): - Bash drift script already supported `prefix...suffix` ellipsis wildcards. Now also resolves the more conventional `prefix*suffix` glob form: `*` is converted to `.*` for the regex match. Same logic in both directions (declared glob suppresses missing-files warnings; modified path matching declared glob suppresses scope-expansion warnings). CHARTER-04 declared `AILOG-*.md` for a bulk set; pre-fix the script extracted the literal and reported spurious drift. O1 documentation (framework, CLI-REFERENCE.md): - New "Wildcard support" subsection in the drift section documenting both ellipsis and glob forms. - New "Designed: governance paths are always in scope" subsection with empirical citation to CHARTER-04 W2 in issue #81. A --strict-scope flag remains on the table for cli-3.8.0. Tests: 11 new (4 unit + 7 integration); 393/393 passing across 16 test groups. What's NOT in this release: F2/F5/F7 (UX bundle for cli-3.8.0); O3 (design discussion); Phase 3 (separately scoped). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5abeeca commit 263f58c

33 files changed

Lines changed: 624 additions & 75 deletions

CHANGELOG.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,103 @@ and this project uses [independent versioning](README.md#versioning) for Framewo
77

88
---
99

10+
## Framework 4.6.2 / CLI 3.7.2 — Phase 2 patches part 2 (F1, F8 + wildcard glob from issue #81 update)
11+
12+
Second round of patches surfaced by Sentinel CHARTER-02..05 telemetry
13+
([issue #81 update](https://github.com/StrangeDaysTech/devtrail/issues/81#issuecomment-update)).
14+
After fw-4.6.1 / cli-3.7.1 fixed F3 / F4 / F6, executing four more
15+
Charters in Sentinel re-prioritized the remaining frictions: **F1
16+
(slug truncation) reproduced 4/4 times consecutively** — was UX
17+
polish in the original report, now the most consistently reproducing
18+
friction in the CLI. **F8 (closed_at)** required manual workaround on
19+
every Charter close (4× consecutive). **Wildcard glob in drift script**
20+
was a new finding from CHARTER-04 that any future bulk Charter would
21+
hit. Plus **observation O1** validated empirically as a feature
22+
(governance paths always-in-scope correctly suppressed governance
23+
noise without hiding a stray `git add -A` of project files).
24+
25+
**Compatibility.** No breaking changes. New behavior is additive
26+
(`--slug` flag, automatic `closed_at` writing, glob resolution in drift).
27+
28+
### Fixed (CLI)
29+
30+
- **F1 — `devtrail charter new` mid-word slug truncation.** A title that
31+
overflows the 50-char slug limit by 1-2 chars used to produce a partial
32+
word fragment (Sentinel CHARTER-04: title ending in `... true`
33+
filename `…-required-t.md`). Two changes:
34+
- `slugify` now backs up to the last `-` boundary at-or-before the
35+
limit and drops any partial token, never producing a mid-word cut.
36+
Conservative: when the next char in the original is a `-` (or
37+
end-of-string), the truncated view is already at a complete
38+
boundary and is kept verbatim.
39+
- New `--slug <value>` flag lets the operator override the
40+
title-derived slug entirely (Sentinel CHARTER-05: title with a
41+
meaningful `… Plan 04 F3` suffix that otherwise gets lost). The
42+
override is normalized through the same slugifier so it cannot
43+
smuggle in characters that break the filename.
44+
45+
- **F8 — `devtrail charter close` did not auto-write `closed_at`.** Per
46+
Sentinel CHARTER-02..05 telemetry, the field had to be added manually
47+
4× consecutively. The CLI now writes `closed_at: <today>` to the
48+
frontmatter alongside the `status: closed` bump. If the Charter
49+
already had a `closed_at` (e.g., a prior close that was reverted),
50+
the value is refreshed to today rather than left stale.
51+
52+
### Fixed (Framework)
53+
54+
- **Drift script wildcard glob resolution.** The bash drift script
55+
already supported the historical `prefix...suffix` ellipsis
56+
wildcard. Now it also resolves the more conventional
57+
`prefix*suffix` glob form: `*` is converted to `.*` for the regex
58+
match, and the same logic applies in both directions (declared
59+
glob suppresses "declared but not modified" when at least one
60+
matching file was modified, and suppresses "modified but not
61+
declared" when a modified path matches a declared glob).
62+
Sentinel CHARTER-04 declared `AILOG-*.md` for a parameterized bulk
63+
set; pre-fix the script extracted the literal string and reported
64+
spurious drift.
65+
66+
### Documented (Framework)
67+
68+
- **O1 ("always in scope" rule for governance paths) is a designed
69+
feature, not a bug.** Empirically validated in Sentinel CHARTER-04:
70+
a stray `git add -A` staged unrelated user-untracked files
71+
(`.claude/skills/`, `cmd/sentinel/sentinel`); the rule correctly
72+
suppressed governance noise without hiding the genuine project-file
73+
expansion. CLI-REFERENCE.md `devtrail charter drift` section now
74+
has explicit subsections for "Wildcard support" and "Designed:
75+
governance paths are always in scope", with the empirical citation
76+
to issue #81 W2. A `--strict-scope` flag that disables the rule
77+
remains on the table for cli-3.8.0 if a real adopter reports the
78+
asymmetry as friction.
79+
80+
### Tests
81+
82+
- 4 new unit tests for the F1 slug helper (mid-word truncation
83+
reproduction, hard-cut fallback for hyphenless slugs, trailing-`-`
84+
trim, helper purity).
85+
- 4 new integration tests for `devtrail charter new` (word-boundary
86+
truncation end-to-end, `--slug` override, `--slug` normalized
87+
through slugifier, empty `--slug` falls back to title).
88+
- 2 new integration tests for F8 (`closed_at` auto-write when
89+
absent, refresh of stale `closed_at` to today).
90+
- 1 new integration test for the drift wildcard glob (real git repo,
91+
Charter declares `component-*.rs`, all matching modified files
92+
satisfy the glob).
93+
- All 16/16 test groups pass (393 individual tests, 11 new on top of
94+
the 382 carried forward from cli-3.7.1).
95+
96+
### What's NOT in this release
97+
98+
- F2 (AILOG context backfill in Origin line) — bundled for cli-3.8.0.
99+
- F5 (high-risk approve warning + verbose) — bundled for cli-3.8.0.
100+
- F7 (charter close output differentiation first-run vs revalidation)
101+
— bundled for cli-3.8.0.
102+
- O3 (`INFO: 0 paths suppressed` always-on log) — pending design discussion.
103+
- Phase 3 (multi-model external audit) — separately scoped.
104+
105+
---
106+
10107
## Framework 4.6.1 / CLI 3.7.1 — Phase 2 patches (F3, F4, F6 from issue #81)
11108

12109
Empirical validation of fw-4.6.0 / cli-3.7.0 in Sentinel CHARTER-02

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,8 @@ DevTrail uses independent version tags for each component:
259259

260260
| Component | Tag prefix | Example | Includes |
261261
|-----------|-----------|---------|----------|
262-
| Framework | `fw-` | `fw-4.6.1` | Templates (12 types), governance, directives, Charter template + schema |
263-
| CLI | `cli-` | `cli-3.7.1` | The `devtrail` binary |
262+
| Framework | `fw-` | `fw-4.6.2` | Templates (12 types), governance, directives, Charter template + schema |
263+
| CLI | `cli-` | `cli-3.7.2` | The `devtrail` binary |
264264

265265
Check installed versions with `devtrail status` or `devtrail about`.
266266

@@ -292,7 +292,7 @@ See [CLI Reference](https://github.com/StrangeDaysTech/devtrail/blob/main/docs/a
292292
```bash
293293
# Download the latest framework release ZIP from GitHub
294294
# Go to https://github.com/StrangeDaysTech/devtrail/releases
295-
# and download the latest fw-* release (e.g., fw-4.6.1)
295+
# and download the latest fw-* release (e.g., fw-4.6.2)
296296
297297
# Extract and copy to your project
298298
unzip devtrail-fw-*.zip -d your-project/

cli/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "devtrail-cli"
3-
version = "3.7.1"
3+
version = "3.7.2"
44
edition = "2021"
55
description = "CLI for DevTrail — the cognitive discipline your AI-assisted projects need"
66
license = "MIT"

cli/src/commands/charter/close.rs

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -489,10 +489,22 @@ fn update_charter_status_to_closed(charter: &Charter) -> Result<()> {
489489
format!("Failed to read Charter file at {}", charter.path.display())
490490
})?;
491491

492-
// Replace the first frontmatter `status:` line.
493-
let mut updated = String::with_capacity(raw.len());
492+
// F8 (cli-3.7.2): also write closed_at: <today> alongside status: closed.
493+
// Per Sentinel CHARTER-02..05 telemetry the field had to be added manually
494+
// 4× consecutively. Schema permits arbitrary additional fields; the test
495+
// suite for fw-4.6.0 confirmed the validator passes through unknown keys.
496+
let today = Local::now().format("%Y-%m-%d").to_string();
497+
498+
// Walk the frontmatter in a single pass:
499+
// - Replace the first `status:` line with `status: closed`.
500+
// - Replace an existing `closed_at:` line with today's date, if present.
501+
// - If `closed_at:` is absent, queue it for insertion right after the
502+
// replaced `status:` line so it sits in a logical place.
503+
let mut updated = String::with_capacity(raw.len() + 32);
494504
let mut in_frontmatter = false;
495-
let mut replaced = false;
505+
let mut status_replaced = false;
506+
let mut closed_at_replaced = false;
507+
let mut closed_at_indent: Option<String> = None;
496508
let mut delim_count = 0;
497509
for line in raw.lines() {
498510
if line.trim() == "---" {
@@ -502,22 +514,53 @@ fn update_charter_status_to_closed(charter: &Charter) -> Result<()> {
502514
updated.push('\n');
503515
continue;
504516
}
505-
if in_frontmatter && !replaced && line.trim_start().starts_with("status:") {
517+
if in_frontmatter && !status_replaced && line.trim_start().starts_with("status:") {
506518
let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
519+
closed_at_indent = Some(leading_ws.clone());
507520
updated.push_str(&format!("{leading_ws}status: closed\n"));
508-
replaced = true;
521+
status_replaced = true;
522+
continue;
523+
}
524+
if in_frontmatter && !closed_at_replaced && line.trim_start().starts_with("closed_at:") {
525+
let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
526+
updated.push_str(&format!("{leading_ws}closed_at: {today}\n"));
527+
closed_at_replaced = true;
509528
continue;
510529
}
511530
updated.push_str(line);
512531
updated.push('\n');
513532
}
514-
if !replaced {
533+
if !status_replaced {
515534
bail!(
516535
"Charter at {} has no `status:` line in frontmatter — cannot bump to closed",
517536
charter.path.display()
518537
);
519538
}
520539

540+
// If closed_at was absent in the original frontmatter, insert it right
541+
// after the replaced status line. We rebuild `updated` rather than tracking
542+
// an insertion point above, since insertion at arbitrary line indices is
543+
// simpler than splicing an index.
544+
let updated = if !closed_at_replaced {
545+
let indent = closed_at_indent.unwrap_or_default();
546+
let needle = format!("{indent}status: closed\n");
547+
let insert = format!("{indent}closed_at: {today}\n");
548+
match updated.find(&needle) {
549+
Some(pos) => {
550+
let after = pos + needle.len();
551+
let mut out = String::with_capacity(updated.len() + insert.len());
552+
out.push_str(&updated[..after]);
553+
out.push_str(&insert);
554+
out.push_str(&updated[after..]);
555+
out
556+
}
557+
// Defensive — shouldn't happen since we just wrote it.
558+
None => updated,
559+
}
560+
} else {
561+
updated
562+
};
563+
521564
// Sync the body status mirror line. Best-effort: if the line is absent or
522565
// has been edited beyond recognition, we leave it alone — the schema has
523566
// already authoritatively recorded the status in frontmatter.

cli/src/commands/charter/new.rs

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub fn run(
2828
from_ailog: Option<&str>,
2929
from_spec: Option<&str>,
3030
title_arg: Option<&str>,
31+
slug_arg: Option<&str>,
3132
) -> Result<()> {
3233
// clap enforces mutual exclusion via conflicts_with — keep this assertion
3334
// as a defense against direct programmatic invocation.
@@ -75,12 +76,20 @@ pub fn run(
7576
})?;
7677

7778
// Build identifiers.
79+
// F1 (cli-3.7.2): `--slug` lets the operator override the title-derived
80+
// slug when the auto-derivation drops meaningful suffixes (e.g.
81+
// `…-plan-04-f3` → cut to `…-plan` because the limit hit). The override
82+
// is normalized through the same slugifier so it cannot smuggle in
83+
// characters that break the filename.
7884
let nn = next_charter_number(project_root);
79-
let slug = slugify(&title);
85+
let slug = match slug_arg {
86+
Some(s) if !s.trim().is_empty() => slugify(s),
87+
_ => slugify(&title),
88+
};
8089
if slug.is_empty() {
8190
bail!(
82-
"Title '{}' produces an empty slug — titles must contain at least one alphanumeric character",
83-
title
91+
"{} produces an empty slug — must contain at least one alphanumeric character",
92+
if slug_arg.is_some() { "--slug" } else { "Title" }
8493
);
8594
}
8695
let charter_id = format!("CHARTER-{:02}-{}", nn, slug);
@@ -247,6 +256,13 @@ fn validate_spec_path(project_root: &Path, spec_path: &str) -> Result<()> {
247256
/// Slugify a title for use in a Charter filename. Mirrors the implementation
248257
/// in `commands::new::slugify` (kept private there — duplicated here to avoid
249258
/// touching the existing command in this PR; consolidate to `utils` later).
259+
///
260+
/// F1 (cli-3.7.2): truncation now respects word boundaries. The previous
261+
/// implementation cut at the 50-char limit and only trimmed a trailing `-`,
262+
/// which produced mid-word slugs like `…-required-t` (truncating "true" to "t"
263+
/// in Sentinel CHARTER-04). The fix is to back up to the last `-` boundary
264+
/// at-or-before the limit, never producing a partial word fragment. Operators
265+
/// who want a fully custom slug pass `--slug` to override this function entirely.
250266
fn slugify(title: &str) -> String {
251267
let lower = title.to_lowercase();
252268
let parts: Vec<&str> = lower
@@ -255,13 +271,42 @@ fn slugify(title: &str) -> String {
255271
.collect();
256272
let slug = parts.join("-");
257273
if slug.chars().count() > 50 {
258-
let truncated: String = slug.chars().take(50).collect();
259-
truncated.trim_end_matches('-').to_string()
274+
truncate_slug_at_word_boundary(&slug, 50)
260275
} else {
261276
slug
262277
}
263278
}
264279

280+
/// Truncate `slug` at-or-before `max_chars`, never splitting a word.
281+
///
282+
/// The function works in two cases:
283+
/// - If `slug[max_chars]` is `-` or end-of-string, then `slug[..max_chars]`
284+
/// already ends on a complete word — we keep the full prefix (after
285+
/// trimming any trailing `-`).
286+
/// - Otherwise, `slug[..max_chars]` ends mid-word; we back up to the last
287+
/// `-` boundary inside the truncated view and drop the partial token.
288+
///
289+
/// Falls back to a hard cut when the truncated view contains no hyphen at
290+
/// all (single very long token).
291+
fn truncate_slug_at_word_boundary(slug: &str, max_chars: usize) -> String {
292+
let truncated: String = slug.chars().take(max_chars).collect();
293+
294+
let next_is_boundary = slug
295+
.chars()
296+
.nth(max_chars)
297+
.map(|c| c == '-')
298+
.unwrap_or(true);
299+
if next_is_boundary {
300+
return truncated.trim_end_matches('-').to_string();
301+
}
302+
303+
let cut = match truncated.rfind('-') {
304+
Some(idx) => &truncated[..idx],
305+
None => truncated.as_str(),
306+
};
307+
cut.trim_end_matches('-').to_string()
308+
}
309+
265310
#[cfg(test)]
266311
mod tests {
267312
use super::*;
@@ -387,6 +432,57 @@ Body content.
387432
assert!(s.len() <= 50);
388433
}
389434

435+
// ── F1 (cli-3.7.2): word-boundary truncation ─────────────────────────
436+
437+
#[test]
438+
fn slugify_truncates_at_word_boundary_not_mid_word() {
439+
// CHARTER-04 reproduction case from issue #81: title that overflows
440+
// 50 chars by 1-2 chars used to produce a mid-word fragment like
441+
// "…required-t" (cutting "true" to "t"). Now the truncation backs up
442+
// to the last `-` boundary and drops the partial token entirely.
443+
let title = "Approve retroactivo bulk de docs review_required: true";
444+
let s = slugify(title);
445+
assert!(s.len() <= 50, "slug must fit limit, got {}: {s}", s.len());
446+
assert!(
447+
!s.ends_with("-t") && !s.ends_with("-tr") && !s.ends_with("-tru"),
448+
"slug must not end with a partial word fragment, got: {s}"
449+
);
450+
// Last completed word should be preserved.
451+
assert!(s.ends_with("required"), "got: {s}");
452+
}
453+
454+
#[test]
455+
fn slugify_handles_no_hyphen_in_truncated_window() {
456+
// Single very long token: no hyphens to back up to. We hard-cut.
457+
let title = "supercalifragilisticexpialidocious".repeat(3);
458+
let s = slugify(&title);
459+
assert!(s.len() <= 50);
460+
assert!(!s.contains('-'));
461+
}
462+
463+
#[test]
464+
fn slugify_strips_trailing_hyphens_after_word_boundary_cut() {
465+
// If the cut lands such that a trailing `-` survives, we trim it.
466+
let title = "abc-def-ghi-jkl-mno-pqr-stu-vwx-yz1-2345-6789-extra";
467+
let s = slugify(title);
468+
assert!(!s.ends_with('-'), "got: {s}");
469+
}
470+
471+
#[test]
472+
fn truncate_slug_at_word_boundary_helper_is_pure() {
473+
// Direct unit test of the helper: cut at last `-` ≤ max_chars.
474+
assert_eq!(
475+
truncate_slug_at_word_boundary("foo-bar-baz-qux", 11),
476+
"foo-bar-baz"
477+
);
478+
assert_eq!(
479+
truncate_slug_at_word_boundary("foo-bar-baz-qux", 10),
480+
"foo-bar"
481+
);
482+
// No hyphen within window → hard cut.
483+
assert_eq!(truncate_slug_at_word_boundary("supercalifragilistic", 10), "supercalif");
484+
}
485+
390486
#[test]
391487
fn next_steps_no_origin_has_4_sequential_numbered_lines() {
392488
let steps = next_steps(None, None);

0 commit comments

Comments
 (0)