phewsh 0.15.54 → 0.15.56

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.
package/lib/md.js CHANGED
@@ -33,6 +33,43 @@ function inlineMd(t, base = '') {
33
33
  (_, x, u) => `${A.uline}${A.teal}${x}${A.reset}${base} ${A.dim}${A.slate}(${u})${A.reset}${base}`);
34
34
  }
35
35
 
36
+ // Soft-wrap a rendered line at word boundaries to the terminal width — this is
37
+ // what replaces the terminal's mid-word hard wrap (CSS `text-wrap: pretty`, in
38
+ // spirit). We never break inside a word. `base` is the block's colour SGR,
39
+ // re-emitted at the start of each continuation line so colour survives the
40
+ // break; `hang` is the continuation indent (hanging indent under list/quote
41
+ // markers). No-op when width is unknown (piped output) so redirected text and
42
+ // tests stay raw.
43
+ const ANSI = /\x1b\[[0-9;]*m/g;
44
+ const vis = (s) => s.replace(ANSI, '').length;
45
+
46
+ function softWrap(s, base = '', hang) {
47
+ const width = process.stdout.columns;
48
+ if (!width || width < 24 || vis(s) <= width) return s;
49
+ const lead = s.match(/^ */)[0].length;
50
+ const indent = hang == null ? lead : hang;
51
+ const words = s.slice(lead).split(/ +/);
52
+ const pad = ' '.repeat(indent);
53
+ const out = [];
54
+ let cur = ' '.repeat(lead);
55
+ let len = lead;
56
+ let started = false;
57
+ for (const word of words) {
58
+ const wl = vis(word);
59
+ if (started && len + 1 + wl > width) {
60
+ out.push(cur + A.reset);
61
+ cur = base + pad + word;
62
+ len = indent + wl;
63
+ } else {
64
+ cur += (started ? ' ' : '') + word;
65
+ len += (started ? 1 : 0) + wl;
66
+ started = true;
67
+ }
68
+ }
69
+ out.push(cur);
70
+ return out.join('\n');
71
+ }
72
+
36
73
  // Render one complete line, mutating `state` for block-level context.
37
74
  // Body text keeps the default terminal foreground (bright) — only structural
38
75
  // elements get colour, so a reply reads as a reply, not as dimmed UI chrome.
@@ -47,12 +84,12 @@ function renderLine(line, state = {}) {
47
84
  }
48
85
  if (/^#{1,2}\s/.test(line)) return `\n${A.bold}${A.teal}${inlineMd(line.replace(/^#+\s*/, ''), A.teal)}${A.reset}`;
49
86
  if (/^#{3,}\s/.test(line)) return `${A.cream}${inlineMd(line.replace(/^#+\s*/, ''), A.cream)}${A.reset}`;
50
- if (/^\s*[-*]\s/.test(line)) return ` ${A.teal}·${A.reset} ${A.sage}${inlineMd(line.replace(/^\s*[-*]\s*/, ''), A.sage)}${A.reset}`;
51
- if (/^\s*\d+\.\s/.test(line)) return ` ${A.sage}${inlineMd(line.trim(), A.sage)}${A.reset}`;
52
- if (/^\s*>\s?/.test(line)) return ` ${A.slate}${A.ital}${inlineMd(line.replace(/^\s*>\s?/, ''), A.slate)}${A.italOff}${A.reset}`;
87
+ if (/^\s*[-*]\s/.test(line)) return softWrap(` ${A.teal}·${A.reset} ${A.sage}${inlineMd(line.replace(/^\s*[-*]\s*/, ''), A.sage)}${A.reset}`, A.sage, 4);
88
+ if (/^\s*\d+\.\s/.test(line)) return softWrap(` ${A.sage}${inlineMd(line.trim(), A.sage)}${A.reset}`, A.sage, 5);
89
+ if (/^\s*>\s?/.test(line)) return softWrap(` ${A.slate}${A.ital}${inlineMd(line.replace(/^\s*>\s?/, ''), A.slate)}${A.italOff}${A.reset}`, A.slate, 4);
53
90
  if (/^---+\s*$/.test(line)) return ` ${A.slate}${'─'.repeat(40)}${A.reset}`;
54
91
  if (line.trim() === '') return '';
55
- return inlineMd(line, ''); // body: default foreground, inline styling only
92
+ return softWrap(inlineMd(line, ''), '', 0); // body: default foreground, inline styling only
56
93
  }
57
94
 
58
95
  // Line-buffered streaming renderer. Feed raw token text via push(); each time a
package/lib/selfheal.js CHANGED
@@ -108,7 +108,9 @@ function upsertBlock(filePath, core, footer, fileLabel, headerText) {
108
108
  const expectedSource = footerSource(footer);
109
109
  if (m && coreOf(m[1]) === coreOf(core) &&
110
110
  (!expectedSource || footerSource(m[1]) === expectedSource)) return false;
111
- const next = existing.replace(re, wrapped);
111
+ // A replacement function keeps canonical text such as "$10" from being
112
+ // interpreted as a regex capture reference and duplicating old content.
113
+ const next = existing.replace(re, () => wrapped);
112
114
  fs.writeFileSync(filePath, next);
113
115
  } else if (existing) {
114
116
  fs.writeFileSync(filePath, existing.replace(/\s*$/, '') + '\n\n' + wrapped + '\n');
@@ -153,7 +155,17 @@ function buildContextCore(cwd = process.cwd()) {
153
155
  let output;
154
156
  try {
155
157
  if (cwd !== prev) process.chdir(cwd);
156
- output = sequence({ target: 'claude-md', write: false }).output;
158
+ output = sequence({
159
+ target: 'claude-md',
160
+ sources: ['intent'],
161
+ sourceNames: ['vision.md', 'project.json', 'status.md', 'next.md', 'next.json'],
162
+ excludeChunkSources: [
163
+ '.intent/project.json:actions',
164
+ '.intent/project.json:responsibilities',
165
+ '.intent/project.json:success',
166
+ ],
167
+ write: false,
168
+ }).output;
157
169
  } finally {
158
170
  if (cwd !== prev) { try { process.chdir(prev); } catch { /* best-effort */ } }
159
171
  }
@@ -33,6 +33,8 @@ const emitters = {
33
33
  * @param {string} options.target - Output format: 'claude-md' | 'stdout' | 'json'
34
34
  * @param {string} options.budget - Token budget: 'minimal' | 'standard' | 'full' | 'unlimited'
35
35
  * @param {string[]} options.sources - Limit to specific source types (null = all)
36
+ * @param {string[]} options.sourceNames - Limit to specific discovered filenames (null = all)
37
+ * @param {string[]} options.excludeChunkSources - Omit exact parsed chunk sources
36
38
  * @param {boolean} options.explain - Show full ranking breakdown
37
39
  * @param {boolean} options.write - Write to file (for claude-md target)
38
40
  * @param {string} options.cwd - Working directory
@@ -43,6 +45,8 @@ function sequence(options = {}) {
43
45
  target = 'claude-md',
44
46
  budget = 'standard',
45
47
  sources: sourceFilter = null,
48
+ sourceNames = null,
49
+ excludeChunkSources = null,
46
50
  explain = false,
47
51
  write = false,
48
52
  includeGlobal = false,
@@ -73,6 +77,9 @@ function sequence(options = {}) {
73
77
  sourceFilter.some(f => s.type === f || s.type.startsWith(f))
74
78
  );
75
79
  }
80
+ if (sourceNames && sourceNames.length > 0) {
81
+ sources = sources.filter(source => sourceNames.includes(source.name));
82
+ }
76
83
 
77
84
  // 2. Parse all sources into chunks
78
85
  let chunks = [];
@@ -83,6 +90,9 @@ function sequence(options = {}) {
83
90
  const parsed = parser.parse(source);
84
91
  chunks.push(...parsed);
85
92
  }
93
+ if (excludeChunkSources && excludeChunkSources.length > 0) {
94
+ chunks = chunks.filter(chunk => !excludeChunkSources.includes(chunk.source));
95
+ }
86
96
 
87
97
  // 3. Rank all chunks
88
98
  chunks = rank(chunks);
@@ -75,12 +75,19 @@ function parseStatus(filePath) {
75
75
  const { meta, body } = parseFrontmatter(content);
76
76
  const mtime = fs.statSync(filePath).mtime.toISOString();
77
77
  const timestamp = meta.updated || meta.created || mtime;
78
+ // Harness projections need the curated current state, not the full journal.
79
+ // Truncate before ranking/compression so a long history cannot crowd the
80
+ // structured Next item and its accepted success criteria out of the budget.
81
+ const lines = body.split('\n');
82
+ let cut = lines.findIndex(line => /^---+\s*$/.test(line));
83
+ if (cut < 0 || cut > 24) cut = 24;
84
+ const current = lines.slice(0, cut).join('\n').trim();
78
85
 
79
86
  return [{
80
87
  source: '.intent/status.md',
81
88
  sourceType: 'intent',
82
89
  kind: 'state',
83
- content: body.trim(),
90
+ content: current,
84
91
  timestamp,
85
92
  metadata: { frontmatter: meta },
86
93
  }];
@@ -91,12 +98,19 @@ function parseNext(filePath) {
91
98
  const { meta, body } = parseFrontmatter(content);
92
99
  const mtime = fs.statSync(filePath).mtime.toISOString();
93
100
  const timestamp = meta.updated || meta.created || mtime;
101
+ const lines = body.split('\n');
102
+ const sectionIndexes = lines
103
+ .map((line, index) => (/^##\s+/.test(line) ? index : -1))
104
+ .filter(index => index >= 0);
105
+ const cut = sectionIndexes.length > 1
106
+ ? sectionIndexes[1]
107
+ : Math.min(lines.length, 24);
94
108
 
95
109
  return [{
96
110
  source: '.intent/next.md',
97
111
  sourceType: 'intent',
98
112
  kind: 'state',
99
- content: body.trim(),
113
+ content: lines.slice(0, cut).join('\n').trim(),
100
114
  timestamp,
101
115
  metadata: { frontmatter: meta },
102
116
  }];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.54",
3
+ "version": "0.15.56",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"