devflow-kit 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +80 -1
  2. package/README.md +6 -2
  3. package/dist/agents/code.md +7 -11
  4. package/dist/agents/fix.md +5 -6
  5. package/dist/agents/refactor.md +5 -6
  6. package/dist/agents/review.md +2 -0
  7. package/dist/cli/commands/gates.js +276 -0
  8. package/dist/cli/commands/init-seed.js +9 -1
  9. package/dist/cli/commands/init.js +31 -8
  10. package/dist/cli/commands/memory.js +18 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/commands/bug-analysis.md +2 -2
  13. package/dist/commands/code-review.md +52 -44
  14. package/dist/commands/debug.md +2 -2
  15. package/dist/commands/dynamic-build.md +53 -61
  16. package/dist/commands/dynamic-plan.md +2 -2
  17. package/dist/commands/dynamic-profile.md +2 -2
  18. package/dist/commands/dynamic-tickets.md +2 -2
  19. package/dist/commands/explore.md +2 -2
  20. package/dist/commands/implement.md +26 -31
  21. package/dist/commands/plan.md +15 -25
  22. package/dist/commands/release.md +2 -2
  23. package/dist/commands/research.md +2 -2
  24. package/dist/commands/resolve.md +2 -2
  25. package/dist/commands/self-review.md +2 -2
  26. package/dist/core/echo-value.js +42 -0
  27. package/dist/core/evidence-policy.js +17 -5
  28. package/dist/core/gate-level.js +213 -0
  29. package/dist/core/manifest.js +26 -10
  30. package/dist/core/model-discovery.js +7 -1
  31. package/dist/core/tracker.js +5 -33
  32. package/dist/learning-off/agents/code.md +7 -11
  33. package/dist/learning-off/agents/fix.md +5 -6
  34. package/dist/learning-off/agents/refactor.md +5 -6
  35. package/dist/learning-off/agents/review.md +2 -0
  36. package/dist/learning-off/commands/code-review.md +52 -44
  37. package/dist/learning-off/commands/debug.md +2 -2
  38. package/dist/learning-off/commands/dynamic-build.md +53 -61
  39. package/dist/learning-off/commands/explore.md +2 -2
  40. package/dist/learning-off/commands/implement.md +26 -31
  41. package/dist/learning-off/commands/plan.md +15 -25
  42. package/dist/learning-off/commands/resolve.md +2 -2
  43. package/dist/learning-off/commands/self-review.md +2 -2
  44. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +7 -9
  45. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +7 -15
  46. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +7 -15
  47. package/dist/targets/claude-code/learning-install.js +15 -3
  48. package/package.json +1 -1
  49. package/src/assets/agents/_partials/_code.mds +5 -6
  50. package/src/assets/agents/code.mds +2 -5
  51. package/src/assets/agents/curate.md +4 -6
  52. package/src/assets/agents/review.mds +2 -0
  53. package/src/assets/agents/synthesize.md +2 -2
  54. package/src/assets/commands/_partials/_settings.mds +2 -2
  55. package/src/assets/commands/_partials/_wave.mds +10 -17
  56. package/src/assets/commands/code-review.mds +50 -42
  57. package/src/assets/commands/dynamic-build.mds +41 -42
  58. package/src/assets/commands/implement.mds +24 -29
  59. package/src/assets/commands/plan.mds +14 -24
  60. package/src/assets/mds/git/_ops_steps.mds +3 -3
  61. package/src/assets/mds/tracker/_common.mds +23 -12
  62. package/src/assets/mds/tracker/_github.mds +2 -9
  63. package/src/assets/mds/tracker/_jira.mds +2 -15
  64. package/src/assets/mds/tracker/_linear.mds +2 -15
  65. package/src/assets/scripts/ci-wait.cjs +22 -4
  66. package/src/assets/scripts/claude-md-audit.cjs +66 -51
  67. package/src/assets/scripts/hooks/background-memory-update +404 -95
  68. package/src/assets/scripts/hooks/json-helper.cjs +11 -0
  69. package/src/assets/scripts/hooks/json-parse +18 -3
  70. package/src/assets/scripts/hooks/memory-permission +358 -0
  71. package/src/assets/scripts/hooks/session-start-context +2 -2
  72. package/src/assets/scripts/hooks/session-start-memory +26 -5
  73. package/src/assets/scripts/lib/project-config.cjs +19 -4
  74. package/src/assets/scripts/resolve-settings.cjs +200 -32
  75. package/src/assets/skills/agent-references/SKILL.md +6 -2
  76. package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
  77. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +2 -2
  78. package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
  79. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
  80. package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
@@ -5,8 +5,6 @@ description: Unified design planning - combines requirements discovery, gap anal
5
5
 
6
6
  Orchestrate design planning from requirements discovery through gap analysis to implementation design. Produces a machine-readable design artifact consumed by `/implement`.
7
7
 
8
- The orchestrator only spawns agents and gates — all analytical work is done by agents.
9
-
10
8
  ## Usage
11
9
 
12
10
  ```
@@ -38,8 +36,6 @@ Note: a bare digit run is a reference **only** under `github`, and that adjudica
38
36
 
39
37
  ## Clarification Gates
40
38
 
41
- **MANDATORY**: Three gates that must complete before proceeding.
42
-
43
39
  | Gate | Phase | Purpose |
44
40
  |------|-------|---------|
45
41
  | Gate 0 | Phase 1 | Requirements discovery before exploration |
@@ -109,7 +105,7 @@ If the user says "skip" or "just proceed" — skip remaining questions, present
109
105
 
110
106
  #### Phase 2: Orient + Load Decisions
111
107
 
112
- **Produces:** SKIM_CONTEXT, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, FEATURE_KNOWLEDGE_RULES
108
+ **Produces:** SKIM_CONTEXT, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, FEATURE_KNOWLEDGE_RULES, GATE_LEVEL
113
109
  **Requires:** CONFIRMED_SCOPE
114
110
 
115
111
  **Resolve the settings line** once per worktree root, reusing a line this run already resolved for the same root. `{root}` is the worktree the values are for — the repository root when the run has one worktree:
@@ -118,9 +114,9 @@ If the user says "skip" or "just proceed" — skip remaining questions, present
118
114
  node "$HOME/.devflow/scripts/resolve-settings.cjs" "{root}" 2>/dev/null; echo "exit=$?"
119
115
  ```
120
116
 
121
- Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off` instead.
117
+ Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off GATE_LEVEL=full` instead.
122
118
 
123
- The accepted line is the only source of these values: the script alone folds the committed `.devflow/project.json`, the personal `.devflow/config.json` and the machine manifest.
119
+ The accepted line is the only source of these values.
124
120
 
125
121
  Spawn Skim agent for codebase context:
126
122
 
@@ -238,10 +234,16 @@ A KB with no Rules section labels its entries `Rules ({section name}):` and give
238
234
 
239
235
  Pass `FEATURE_KNOWLEDGE` to Explore and Design agents. Pass `DECISIONS_CONTEXT` to Design agents only.
240
236
 
237
+ **Gate level.** At `standard` or `lean`, once per run, run `d="${CLAUDE_CONFIG_DIR:-}"; case "$d" in /*) ;; *) d="$HOME/.claude" ;; esac; printf '%s\n' "$d"` in Bash and Read `<that directory>/skills/devflow:agent-references/references/commands/plan-levels.md` with the Read tool, and apply it to Phases 3-14.
238
+
239
+ - No row or rule overrides the next two lines.
240
+ - If the Read fails, or a row is missing or malformed, set `GATE_LEVEL` to `full` and say `level reference unreadable: full applied`.
241
+ - Security runs as its own Design spawn, and the design review runs, at every level and on every Revise pass.
242
+
241
243
  #### Phase 3: Explore Requirements (Parallel)
242
244
 
243
245
  **Produces:** EXPLORE_OUTPUTS
244
- **Requires:** SKIM_CONTEXT
246
+ **Requires:** SKIM_CONTEXT, GATE_LEVEL
245
247
 
246
248
  Spawn 4 `Agent(subagent_type="Explore")` agents **in a single message**, each with Skim agent context and `FEATURE_KNOWLEDGE: {feature_knowledge}` (from Phase 2). Include the instruction: "The FEATURE_KNOWLEDGE is a baseline — VALIDATE, EXTEND, and CORRECT it. For anything it already covers, cite its KB IDs (`{slug} KB-AP-n`) instead of restating the text. Focus on areas the feature knowledge doesn't cover and changes since it was last updated." Ask each agent for a final report of at most about 1,500 tokens: findings with file:line references, not file dumps.
247
249
 
@@ -257,8 +259,6 @@ Spawn 4 `Agent(subagent_type="Explore")` agents **in a single message**, each wi
257
259
  **Produces:** EXPLORATION_SYNTHESIS
258
260
  **Requires:** EXPLORE_OUTPUTS
259
261
 
260
- **WAIT** for Phase 3 to complete.
261
-
262
262
  ```
263
263
  Agent(subagent_type="Synthesize"):
264
264
  "Synthesize EXPLORATION outputs for: {feature/issues}
@@ -319,7 +319,7 @@ Focus: {completeness|architecture|security|performance|compliance|consistency|de
319
319
  DECISIONS_CONTEXT: {decisions_context}
320
320
  FEATURE_KNOWLEDGE: {feature_knowledge}
321
321
  {COMPLIANCE_FOCUS_DIRECTIVE} (compliance focus only)
322
- Resolve `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` in Bash, then Read `<that directory>/skills/devflow:gap-analysis/references/compliance.md` with the Read tool and apply it as your focus patterns. (compliance focus only)
322
+ Run `d="${CLAUDE_CONFIG_DIR:-}"; case "$d" in /*) ;; *) d="$HOME/.claude" ;; esac; printf '%s\n' "$d"` in Bash, then Read `<that directory>/skills/devflow:gap-analysis/references/compliance.md` with the Read tool and apply it as your focus patterns. (compliance focus only)
323
323
  Artifacts:
324
324
  Feature/Issues: {feature description or issue bodies}
325
325
  Exploration synthesis: {Phase 4 output}
@@ -333,8 +333,6 @@ Cite evidence from provided artifacts."
333
333
  **Produces:** GAP_SYNTHESIS
334
334
  **Requires:** GAP_OUTPUTS
335
335
 
336
- **WAIT** for Phase 5 to complete.
337
-
338
336
  ```
339
337
  Agent(subagent_type="Synthesize"):
340
338
  "Synthesize GAP ANALYSIS outputs for: {feature/issues}
@@ -395,8 +393,6 @@ Spawn 4 `Agent(subagent_type="Explore")` agents **in a single message**, each wi
395
393
  **Produces:** IMPL_EXPLORATION_SYNTHESIS
396
394
  **Requires:** IMPL_EXPLORE_OUTPUTS
397
395
 
398
- **WAIT** for Phase 8 to complete.
399
-
400
396
  ```
401
397
  Agent(subagent_type="Synthesize"):
402
398
  "Synthesize IMPLEMENTATION EXPLORATION outputs for: {feature/issues}
@@ -410,7 +406,7 @@ Combine into: patterns to follow, integration points, reusable code, edge cases"
410
406
  **Produces:** PLAN_OUTPUTS
411
407
  **Requires:** IMPL_EXPLORATION_SYNTHESIS, GAP_SYNTHESIS
412
408
 
413
- Spawn 3 Plan agents **in a single message**, each with implementation exploration synthesis. Ask each agent for a final report of at most about 1,500 tokens: the plan itself, not a restatement of the exploration.
409
+ Spawn 3 `Agent(subagent_type="Plan")` agents **in a single message**, each with implementation exploration synthesis. Ask each agent for a final report of at most about 1,500 tokens: the plan itself, not a restatement of the exploration.
414
410
 
415
411
  | Focus | Output |
416
412
  |-------|--------|
@@ -425,8 +421,6 @@ Implementation steps planner: include explicit gap mitigations (from Phase 6) in
425
421
  **Produces:** PLANNING_SYNTHESIS
426
422
  **Requires:** PLAN_OUTPUTS
427
423
 
428
- **WAIT** for Phase 10 to complete.
429
-
430
424
  ```
431
425
  Agent(subagent_type="Synthesize"):
432
426
  "Synthesize PLANNING outputs for: {feature/issues}
@@ -627,6 +621,7 @@ Display completion summary:
627
621
  - Issue URL (if created or enriched) — and any `TRACEABILITY: DEGRADED ({reason})` lines from the Git agent
628
622
  - Gap analysis summary (N blocking, M should-address)
629
623
  - Design review summary (N anti-patterns found, M mitigated in plan)
624
+ - Gate level: {GATE_LEVEL}, with any level note
630
625
  - Suggested next step: `/implement {artifact-path}` or `/implement #{issue-number}`
631
626
 
632
627
  ---
@@ -670,13 +665,8 @@ Display completion summary:
670
665
 
671
666
  ## Principles
672
667
 
673
- 1. **Orchestration only** — Command spawns agents, never does agent work itself
674
- 2. **Three mandatory gates** — Gate 0 (understand), Gate 1 (scope+gaps), Gate 2 (plan+review); none may be skipped
675
- 3. **Parallel execution** — Explore phases and gap analysis run in parallel; synthesis phases wait
676
- 4. **Evidence-based gaps** — Every gap cites specific text; no speculation
677
- 5. **Scope ruthlessly** — Small, focused plans ship faster; gate 1 enforces scope discipline
678
- 6. **Strict delegation** — Never synthesize, analyze, or plan in main session; always spawn agents
679
- 7. **Design artifacts are machine-readable** — `/implement` can consume the YAML frontmatter directly
668
+ 1. **Scope ruthlessly** — Small, focused plans ship faster; gate 1 enforces scope discipline
669
+ 2. **Strict delegation** — Never synthesize, analyze, or plan in main session; always spawn agents
680
670
 
681
671
  ## Error Handling
682
672
 
@@ -58,9 +58,9 @@ Read `.release/RELEASE-FLOW.md`:
58
58
  node "$HOME/.devflow/scripts/resolve-settings.cjs" "{root}" 2>/dev/null; echo "exit=$?"
59
59
  ```
60
60
 
61
- Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off` instead.
61
+ Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off GATE_LEVEL=full` instead.
62
62
 
63
- The accepted line is the only source of these values: the script alone folds the committed `.devflow/project.json`, the personal `.devflow/config.json` and the machine manifest.
63
+ The accepted line is the only source of these values.
64
64
 
65
65
  When the settings line says `LEARNING=off`, set `DECISIONS_CONTEXT` to `(none)` and skip this step, locating no ledger and reading no index.
66
66
 
@@ -47,9 +47,9 @@ and using its one-line output. If the command fails (outside a git repository),
47
47
  node "$HOME/.devflow/scripts/resolve-settings.cjs" "{root}" 2>/dev/null; echo "exit=$?"
48
48
  ```
49
49
 
50
- Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off` instead.
50
+ Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off GATE_LEVEL=full` instead.
51
51
 
52
- The accepted line is the only source of these values: the script alone folds the committed `.devflow/project.json`, the personal `.devflow/config.json` and the machine manifest.
52
+ The accepted line is the only source of these values.
53
53
 
54
54
  ### Load DECISIONS_CONTEXT
55
55
 
@@ -94,9 +94,9 @@ Set `TARGET_DIR` to the selected review or bug-analysis directory path, and `TAR
94
94
  node "$HOME/.devflow/scripts/resolve-settings.cjs" "{root}" 2>/dev/null; echo "exit=$?"
95
95
  ```
96
96
 
97
- Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off` instead.
97
+ Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off GATE_LEVEL=full` instead.
98
98
 
99
- The accepted line is the only source of these values: the script alone folds the committed `.devflow/project.json`, the personal `.devflow/config.json` and the machine manifest.
99
+ The accepted line is the only source of these values.
100
100
 
101
101
  ### Load DECISIONS_CONTEXT
102
102
 
@@ -30,9 +30,9 @@ Detect changed files and build context:
30
30
  node "$HOME/.devflow/scripts/resolve-settings.cjs" "{root}" 2>/dev/null; echo "exit=$?"
31
31
  ```
32
32
 
33
- Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off` instead.
33
+ Accept the output only when it is exactly two lines: `exit=0` last and, before it, one line of the form `TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default> TRACKER_WARN=<none|mismatch|invalid> SITE=<none|https://<host>> KEY=<none|<key>> REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|<id>[,<id>…]> MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>` — these fields, in this order, nothing else, where `<host>` is a lowercase dotted host name alone, `<key>` is 2–10 of `A-Z`, `0-9` and `_` starting with a letter, and each `<id>` is one of `gdpr`, `hipaa`, `pci-dss`, `soc2`, `iso-27001`, `sox`. **Anything else** (a non-zero exit, no line, extra text, or a missing, reordered or unlisted field or value) ⇒ use `TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off GATE_LEVEL=full` instead.
34
34
 
35
- The accepted line is the only source of these values: the script alone folds the committed `.devflow/project.json`, the personal `.devflow/config.json` and the machine manifest.
35
+ The accepted line is the only source of these values.
36
36
 
37
37
  ### Load DECISIONS_CONTEXT
38
38
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Render an untrusted value for a terminal message.
3
+ *
4
+ * A neutral home for the sanitizer that every parser echoing a rejected value
5
+ * shares (the tracker provider and the gate level today), so no parser imports
6
+ * another feature's module just to reach it.
7
+ */
8
+ /** Longest rejected value echoed back to the terminal, in CODE POINTS. */
9
+ const MAX_ECHOED_VALUE = 40;
10
+ /**
11
+ * Render an untrusted token for a terminal message.
12
+ *
13
+ * Rejected values are echoed so the user can see what they typed, so they are
14
+ * third-party input at a display sink: control characters (terminal escapes,
15
+ * BEL, newlines) are replaced and the value is truncated.
16
+ *
17
+ * Takes `unknown`, and a non-string renders as its TYPE: the never-throws
18
+ * contract of the callers has to hold for what reaches this sink, not
19
+ * only for what the signature says — a caller reads hand-editable
20
+ * files, and a caller-side guard is one edit from being gone. Naming the type
21
+ * also keeps the render total, where `String()` would hand control to a
22
+ * caller-supplied `toString` — itself both a throw path and an echo path this
23
+ * function exists to close.
24
+ */
25
+ export function describeEchoedValue(raw) {
26
+ if (typeof raw !== 'string')
27
+ return `<${raw === null ? 'null' : typeof raw}>`;
28
+ // The class is written with ESCAPES, never literal control bytes. A raw NUL
29
+ // makes grep classify this whole file as binary — it prints "Binary file
30
+ // matches" and skips the lines — so every grep-based sweep over src/core/
31
+ // silently stops covering the file while still exiting 0. Pinned by
32
+ // tests/guards/no-control-bytes.test.ts.
33
+ // eslint-disable-next-line no-control-regex
34
+ const stripped = raw.replace(/[\x00-\x1f\x7f]/g, '?');
35
+ // Cut by CODE POINT. `slice` cuts UTF-16 code units, so a cut landing inside an
36
+ // astral character (emoji, CJK ext-B) emits the lone surrogate half of it.
37
+ const points = [...stripped];
38
+ return points.length > MAX_ECHOED_VALUE
39
+ ? `${points.slice(0, MAX_ECHOED_VALUE).join('')}…`
40
+ : stripped;
41
+ }
42
+ //# sourceMappingURL=echo-value.js.map
@@ -331,15 +331,27 @@ export function repoComplianceStatusLines(loaded, opts) {
331
331
  * tracked by git, or null (D-PERSONAL-UNTRACKED). The resolver ignores such a file
332
332
  * and says so on stderr, but prompts run it with stderr discarded, so a status
333
333
  * command is where the user sees why their personal settings have no effect.
334
+ *
335
+ * Takes the ONE resolved result, so a caller that already holds it adds no second
336
+ * resolve (each resolve forks git).
337
+ */
338
+ export function trackedConfigWarning(settings) {
339
+ if (!settings.personalTracked)
340
+ return null;
341
+ const file = settingsSourceFile('personal');
342
+ return `Git tracks an entry at or under ${file}, so devflow ignores that file. It holds personal settings. ` +
343
+ 'Find the entry in the output of: git ls-files. Untrack it with: git rm --cached <entry>. ' +
344
+ 'Then check the file contents, or delete the file. ' +
345
+ 'Untracking leaves the committed bytes on disk as your personal file.';
346
+ }
347
+ /**
348
+ * {@link trackedConfigWarning} for a caller that holds the loaded module and not a
349
+ * resolved result: it resolves once and returns null when the module is unusable.
334
350
  */
335
351
  export function personalConfigTrackedWarning(loaded, opts) {
336
352
  if (!loaded.ok)
337
353
  return null;
338
- if (!loaded.value.resolveSettings(opts).personalTracked)
339
- return null;
340
- const file = settingsSourceFile('personal');
341
- return `${file} is tracked by git, so devflow ignores it — it holds personal settings. ` +
342
- `Untrack it with: git rm --cached ${file}`;
354
+ return trackedConfigWarning(loaded.value.resolveSettings(opts));
343
355
  }
344
356
  /** The policies the hint maps, in the order it prints them. */
345
357
  const HINT_POLICIES = ['standard', 'required'];
@@ -0,0 +1,213 @@
1
+ import { promises as fs, constants as fsConstants } from 'fs';
2
+ import * as path from 'path';
3
+ import { writeFileAtomicExclusive } from './fs-atomic.js';
4
+ import { hasRequiredManifestFields } from './manifest.js';
5
+ import { describeEchoedValue } from './echo-value.js';
6
+ /**
7
+ * The gate level: how much checking a command applies. One of three words, from
8
+ * most to least checking.
9
+ *
10
+ * `features.gateLevel` in `~/.devflow/manifest.json` is the MACHINE default, and
11
+ * `gateLevel` in a repository's personal `.devflow/config.json` overrides it.
12
+ * Neither is a feature switch: a switch can only be narrowed by a repository
13
+ * layer, but a level may be raised or lowered by the personal file. The settings
14
+ * resolver (`resolve-settings.cjs`) folds both into the `GATE_LEVEL=` field of its
15
+ * settings line; this module owns the closed set, the parser and the machine
16
+ * layer's raw reader and writer. No code here chooses a level from a task or a
17
+ * diff.
18
+ *
19
+ * A present value that is not one of the three words is MALFORMED. A reader reads
20
+ * it as `full` and leaves it on disk as written: no read and no unrelated write
21
+ * heals it to absent, which would lower the level without a word.
22
+ */
23
+ export const GATE_LEVELS = Object.freeze(['full', 'standard', 'lean']);
24
+ /** The level when neither the personal file nor the manifest records one. */
25
+ export const DEFAULT_GATE_LEVEL = 'standard';
26
+ /**
27
+ * Largest manifest read, in bytes. Pinned by a parity test to the resolver
28
+ * library's MAX_MANIFEST_BYTES, so the CLI and a prompt never disagree about a
29
+ * manifest that is too large to read.
30
+ */
31
+ export const MAX_MANIFEST_BYTES = 1048576;
32
+ const VALID_LEVELS_LIST = GATE_LEVELS.join(', ');
33
+ function isPlainObject(value) {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ }
36
+ function isGateLevel(value) {
37
+ return typeof value === 'string' && GATE_LEVELS.includes(value);
38
+ }
39
+ /**
40
+ * Strict boundary parser for a gate level typed on the command line.
41
+ *
42
+ * REJECT, NEVER REPAIR: membership is byte-exact. No trim, no case folding, no
43
+ * alias. `Lean` and `lean ` are errors, not `lean`. The message names every valid
44
+ * level and echoes the offending value only in the sanitized form of
45
+ * {@link describeEchoedValue}.
46
+ */
47
+ export function parseGateLevel(value) {
48
+ if (isGateLevel(value))
49
+ return { ok: true, value };
50
+ if (value === undefined || value === '') {
51
+ return { ok: false, error: `Missing gate level. Valid levels: ${VALID_LEVELS_LIST}` };
52
+ }
53
+ return {
54
+ ok: false,
55
+ error: `Unknown gate level: "${describeEchoedValue(value)}". Valid levels: ${VALID_LEVELS_LIST}`,
56
+ };
57
+ }
58
+ /**
59
+ * The machine layer's level in a RAW parsed manifest. Pure and total.
60
+ *
61
+ * null no level is recorded: not an object, `features` not an object, or
62
+ * no own `gateLevel` key
63
+ * 'invalid' a `gateLevel` key is present and is not one of the three words
64
+ * a level the recorded level
65
+ *
66
+ * Deliberately NOT built on readManifest(): that reader rejects a manifest
67
+ * missing a required field and heal-writes, and a read-only status must do
68
+ * neither. The resolver script's `machineGateLevel` is the CommonJS twin; a
69
+ * table test holds the two to the same rows.
70
+ */
71
+ export function machineGateLevel(raw) {
72
+ if (!isPlainObject(raw))
73
+ return null;
74
+ const features = raw.features;
75
+ if (!isPlainObject(features))
76
+ return null;
77
+ if (!Object.hasOwn(features, 'gateLevel'))
78
+ return null;
79
+ const value = features.gateLevel;
80
+ return isGateLevel(value) ? value : 'invalid';
81
+ }
82
+ /**
83
+ * Set `features.gateLevel` in a RAW parsed manifest. Pure — returns a new object
84
+ * and never mutates its input. Null when the top level or `features` is not a
85
+ * plain object (there is no `features` record to write into).
86
+ *
87
+ * Only `features.gateLevel` and `updatedAt` change; every other key is carried
88
+ * verbatim, including keys this build does not model.
89
+ */
90
+ export function setMachineGateLevel(raw, level, now) {
91
+ if (!isPlainObject(raw) || !isPlainObject(raw.features))
92
+ return null;
93
+ return {
94
+ ...raw,
95
+ features: { ...raw.features, gateLevel: level },
96
+ updatedAt: now,
97
+ };
98
+ }
99
+ const ABSENT = Object.freeze({ kind: 'absent' });
100
+ const CORRUPT = Object.freeze({ kind: 'corrupt' });
101
+ function startsWithBom(bytes) {
102
+ return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
103
+ }
104
+ /** Bytes of the manifest, or why not: one bounded read of a regular file, followed through links. */
105
+ async function readManifestBytes(manifestPath) {
106
+ try {
107
+ let st;
108
+ try {
109
+ st = await fs.stat(manifestPath);
110
+ }
111
+ catch (err) {
112
+ const code = err.code;
113
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'corrupt';
114
+ }
115
+ if (!st.isFile() || st.size > MAX_MANIFEST_BYTES)
116
+ return 'corrupt';
117
+ // O_NONBLOCK: a FIFO swapped in after the stat cannot block the open.
118
+ const handle = await fs.open(manifestPath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK || 0));
119
+ try {
120
+ const opened = await handle.stat();
121
+ if (!opened.isFile() || opened.size > MAX_MANIFEST_BYTES)
122
+ return 'corrupt';
123
+ // One byte past the stat'd size: a file that grew between the stat and the
124
+ // read is refused rather than truncated into something that parses. Each
125
+ // pass reads at least one byte or stops, so the loop is bounded by the buffer.
126
+ const buf = Buffer.alloc(opened.size + 1);
127
+ let total = 0;
128
+ for (let pass = 0; pass < buf.length && total < buf.length; pass++) {
129
+ const { bytesRead } = await handle.read(buf, total, buf.length - total, null);
130
+ if (bytesRead === 0)
131
+ break;
132
+ total += bytesRead;
133
+ }
134
+ return total > opened.size ? 'corrupt' : buf.subarray(0, total);
135
+ }
136
+ finally {
137
+ await handle.close().catch(() => undefined);
138
+ }
139
+ }
140
+ catch {
141
+ return 'corrupt';
142
+ }
143
+ }
144
+ /**
145
+ * Read `<devflowDir>/manifest.json` raw, for the gate level. Never throws, never
146
+ * exits and never writes: unlike readManifest() it heals nothing, models no key
147
+ * and keeps every key it does not name.
148
+ *
149
+ * It follows the resolver library's rules, so the CLI and a prompt see the same
150
+ * file: the path is followed through links, a missing path is absent, and every
151
+ * other failure is corrupt. Bytes decode as UTF-8 with a fatal error, which also
152
+ * strips a leading BOM, and then parse as JSON, so the level is read past a BOM
153
+ * as it is in the resolver. A file with a BOM is still `rejected`, because
154
+ * readManifest() parses the text as read and refuses it.
155
+ */
156
+ export async function readGateManifest(devflowDir) {
157
+ const bytes = await readManifestBytes(path.join(devflowDir, 'manifest.json'));
158
+ if (bytes === 'absent')
159
+ return ABSENT;
160
+ if (bytes === 'corrupt')
161
+ return CORRUPT;
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
165
+ }
166
+ catch {
167
+ return CORRUPT;
168
+ }
169
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.features))
170
+ return CORRUPT;
171
+ const readable = !startsWithBom(bytes) && hasRequiredManifestFields(parsed);
172
+ return { kind: readable ? 'object' : 'rejected', raw: parsed };
173
+ }
174
+ /**
175
+ * Write the machine default to `<devflowDir>/manifest.json` (atomic temp +
176
+ * rename). Never throws. Refuses with `invalid-level`, before any I/O, a level
177
+ * that is not one of the three words, because the type is no check at run time.
178
+ * Refuses with `not-installed` when there is no manifest to write into — a
179
+ * manifest is created by `devflow init`, never by this write.
180
+ *
181
+ * Only `features.gateLevel` and `updatedAt` change. A write to the level the
182
+ * manifest already records changes nothing, `updatedAt` included, because that
183
+ * field means "the manifest's content last changed". A manifest that
184
+ * readManifest() would reject for another key gets the same write as one it
185
+ * accepts: its level is valid, and the fault is not this write's to fix.
186
+ *
187
+ * `now` is injected so a test fixes the timestamp.
188
+ */
189
+ export async function writeMachineGateLevel(devflowDir, level, now) {
190
+ if (!isGateLevel(level))
191
+ return { kind: 'invalid-level' };
192
+ const manifestPath = path.join(devflowDir, 'manifest.json');
193
+ const read = await readGateManifest(devflowDir);
194
+ if (read.kind === 'absent')
195
+ return { kind: 'not-installed' };
196
+ if (read.kind === 'corrupt')
197
+ return { kind: 'unreadable', path: manifestPath };
198
+ const features = read.raw.features;
199
+ if (isPlainObject(features) && features.gateLevel === level)
200
+ return { kind: 'unchanged' };
201
+ try {
202
+ const next = setMachineGateLevel(read.raw, level, now());
203
+ if (next === null)
204
+ return { kind: 'unreadable', path: manifestPath };
205
+ await writeFileAtomicExclusive(manifestPath, JSON.stringify(next, null, 2) + '\n');
206
+ return { kind: 'written' };
207
+ }
208
+ catch (err) {
209
+ const code = err?.code;
210
+ return { kind: 'write-failed', path: manifestPath, reason: typeof code === 'string' ? code : 'unknown error' };
211
+ }
212
+ }
213
+ //# sourceMappingURL=gate-level.js.map
@@ -51,6 +51,27 @@ function parseManifestFlags(features, knownFlags) {
51
51
  // Case C: missing/malformed → empty record
52
52
  return { flags: {}, legacy: false };
53
53
  }
54
+ /**
55
+ * Whether a parsed manifest object carries every field {@link readManifest}
56
+ * requires: a version, a plugin list, a `features` object with boolean `ambient`
57
+ * and `memory`, and string `installedAt` and `updatedAt`. Pure. A manifest that
58
+ * fails it reads as "no prior install".
59
+ *
60
+ * Exported so a raw reader can tell a manifest this reader rejects from one it
61
+ * accepts without calling it: readManifest heal-writes, and a read-only status
62
+ * must not.
63
+ */
64
+ export function hasRequiredManifestFields(data) {
65
+ const features = data.features;
66
+ return !(!data.version ||
67
+ !Array.isArray(data.plugins) ||
68
+ typeof features !== 'object' ||
69
+ features === null ||
70
+ typeof features.ambient !== 'boolean' ||
71
+ typeof features.memory !== 'boolean' ||
72
+ typeof data.installedAt !== 'string' ||
73
+ typeof data.updatedAt !== 'string');
74
+ }
54
75
  /**
55
76
  * Read and parse the manifest file. Returns null if missing or corrupt.
56
77
  *
@@ -75,17 +96,9 @@ export async function readManifest(devflowDir, opts) {
75
96
  try {
76
97
  const content = await fs.readFile(manifestPath, 'utf-8');
77
98
  const data = JSON.parse(content);
78
- const features = data.features;
79
- if (!data.version ||
80
- !Array.isArray(data.plugins) ||
81
- typeof features !== 'object' ||
82
- features === null ||
83
- typeof features.ambient !== 'boolean' ||
84
- typeof features.memory !== 'boolean' ||
85
- typeof data.installedAt !== 'string' ||
86
- typeof data.updatedAt !== 'string') {
99
+ if (!hasRequiredManifestFields(data))
87
100
  return null;
88
- }
101
+ const features = data.features;
89
102
  // D-FEATURES-ABSENT-ON (a sub-decision of D-FEATURES-NARROW-ONLY,
90
103
  // src/core/feature-switch.ts): knowledge and learning are machine-wide
91
104
  // switches, and every runtime gate — queue_read_gates in the hooks,
@@ -172,6 +185,9 @@ export async function readManifest(devflowDir, opts) {
172
185
  // self-heal behaviour, and a different condition from a per-repo config
173
186
  // value outside the domain (which does emit `unknown tracker provider`).
174
187
  tracker: normalizeTrackerFeature(features.tracker, opts?.onWarning),
188
+ // The machine gate level, raw (see the field's doc comment): kept when the
189
+ // key is present, a stored `null` included, and left out when it is absent.
190
+ ...(Object.hasOwn(features, 'gateLevel') ? { gateLevel: features.gateLevel } : {}),
175
191
  },
176
192
  installedAt: data.installedAt,
177
193
  updatedAt: data.updatedAt,
@@ -61,9 +61,15 @@ const STDOUT_CAP = 262_144;
61
61
  * within this window receives SIGTERM followed by a SIGKILL escalation after
62
62
  * SIGKILL_GRACE_MS. Zero retries — the stale cache is the retry.
63
63
  *
64
+ * The window must outlast a cold start on a loaded machine: the first exec of
65
+ * node with the routing runtime took 1.0-5.4 s in measurement while the OS vetted
66
+ * the new process. A shorter window reports an installed runtime as unknown. The
67
+ * test harness bounds (tests/model-discovery.test.ts) use a wider margin than this
68
+ * measured range.
69
+ *
64
70
  * Exported so tests can derive timing bounds without mirroring the literal.
65
71
  */
66
- export const SPAWN_TIMEOUT_MS = 2_000;
72
+ export const SPAWN_TIMEOUT_MS = 6_000;
67
73
  /**
68
74
  * Grace period (ms) between SIGTERM and SIGKILL escalation. Unreffed so the
69
75
  * timer never prevents the Node.js event loop from exiting on its own.
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { promises as fs } from 'fs';
30
30
  import * as path from 'path';
31
+ import { describeEchoedValue as describeTrackerValue } from './echo-value.js';
31
32
  /**
32
33
  * Canonical issue-tracker provider registry.
33
34
  *
@@ -193,8 +194,6 @@ export const TRACKER_ATTEMPTS_MAX = 5;
193
194
  // ---------------------------------------------------------------------------
194
195
  const REGISTRY_SET = new Set(TRACKER_PROVIDER_IDS);
195
196
  const VALID_IDS_LIST = TRACKER_PROVIDER_IDS.join(', ');
196
- /** Longest rejected value echoed back to the terminal, in CODE POINTS. */
197
- const MAX_ECHOED_VALUE = 40;
198
197
  function errorMessage(err) {
199
198
  return err instanceof Error ? err.message : String(err);
200
199
  }
@@ -217,38 +216,11 @@ export function isTrackerProvider(value) {
217
216
  return typeof value === 'string' && REGISTRY_SET.has(value);
218
217
  }
219
218
  /**
220
- * Render an untrusted provider token for a terminal message.
221
- *
222
- * Rejected values are echoed so the user can see what they typed, so they are
223
- * third-party input at a display sink: control characters (terminal escapes,
224
- * BEL, newlines) are replaced and the value is truncated. Used by
225
- * `parseTrackerId`'s error text and by `devflow tracker --status`.
226
- *
227
- * Takes `unknown`, and a non-string renders as its TYPE: the module's
228
- * never-throws contract has to hold for what reaches this sink, not
229
- * only for what the signature says does — `devflow tracker --status` reads
230
- * a conventions file's hand-editable frontmatter, and a caller-side guard is one edit
231
- * from being gone. Naming the type also keeps the render total, where `String()`
232
- * would hand control to a caller-supplied `toString` — itself both a throw path
233
- * and an echo path this function exists to close.
219
+ * Render an untrusted provider token for a terminal message. Used by
220
+ * `parseTrackerId`'s error text and by `devflow tracker --status`. The sanitizer
221
+ * itself lives in `echo-value.ts`; this is its tracker-facing name.
234
222
  */
235
- export function describeTrackerValue(raw) {
236
- if (typeof raw !== 'string')
237
- return `<${raw === null ? 'null' : typeof raw}>`;
238
- // The class is written with ESCAPES, never literal control bytes. A raw NUL
239
- // makes grep classify this whole file as binary — it prints "Binary file
240
- // matches" and skips the lines — so every grep-based sweep over src/core/
241
- // silently stops covering tracker.ts while still exiting 0. Pinned by
242
- // tests/guards/no-control-bytes.test.ts.
243
- // eslint-disable-next-line no-control-regex
244
- const stripped = raw.replace(/[\x00-\x1f\x7f]/g, '?');
245
- // Cut by CODE POINT. `slice` cuts UTF-16 code units, so a cut landing inside an
246
- // astral character (emoji, CJK ext-B) emits the lone surrogate half of it.
247
- const points = [...stripped];
248
- return points.length > MAX_ECHOED_VALUE
249
- ? `${points.slice(0, MAX_ECHOED_VALUE).join('')}…`
250
- : stripped;
251
- }
223
+ export { describeTrackerValue };
252
224
  /**
253
225
  * Strict boundary parser for a provider token (`--tracker <id>`,
254
226
  * `devflow tracker --set <id>`).