atris 3.47.0 → 3.48.1

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.
@@ -0,0 +1,448 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
4
+ const PROCESS_START_TOLERANCE_MS = 30 * 60 * 1000;
5
+ const ACTIVE_TASK_STATUSES = new Set(['claimed', 'do', 'doing', 'in_progress', 'review']);
6
+ const ACTIVE_MISSION_STATUSES = new Set(['planning', 'active', 'running', 'ready']);
7
+ const RUNNING_RECEIPT_STATUSES = new Set(['active', 'in_progress', 'running', 'started', 'working']);
8
+ const TERMINAL_RECEIPT_STATUSES = new Set([
9
+ 'cancelled',
10
+ 'completed',
11
+ 'done',
12
+ 'failed',
13
+ 'landed',
14
+ 'no_output',
15
+ 'passed',
16
+ 'presumed_dead',
17
+ 'succeeded',
18
+ 'timed_out',
19
+ ]);
20
+ const WORK_TOKEN_STOP_WORDS = new Set([
21
+ 'agent', 'build', 'building', 'engine', 'local', 'mission', 'process', 'running', 'task', 'working',
22
+ ]);
23
+
24
+ function timestampMs(value) {
25
+ if (value == null || value === '') return 0;
26
+ if (typeof value === 'number') {
27
+ if (!Number.isFinite(value)) return 0;
28
+ return value > 1000000000000 ? value : value * 1000;
29
+ }
30
+ const numeric = Number(value);
31
+ if (Number.isFinite(numeric) && String(value).trim()) return timestampMs(numeric);
32
+ const parsed = Date.parse(String(value));
33
+ return Number.isFinite(parsed) ? parsed : 0;
34
+ }
35
+
36
+ function isoTimestamp(value) {
37
+ const ms = timestampMs(value);
38
+ return ms ? new Date(ms).toISOString() : null;
39
+ }
40
+
41
+ function ageSeconds(value, nowMs) {
42
+ const ms = timestampMs(value);
43
+ return ms ? Math.max(0, Math.floor((nowMs - ms) / 1000)) : null;
44
+ }
45
+
46
+ function normalizeEngine(value) {
47
+ const engine = String(value || '').trim().toLowerCase();
48
+ if (!engine) return '';
49
+ if (engine === 'cursor-agent' || engine === 'cursor agent') return 'cursor';
50
+ if (engine === 'claude-code') return 'claude';
51
+ return engine.replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
52
+ }
53
+
54
+ function engineForCommand(command) {
55
+ const text = String(command || '');
56
+ if (/ChatGPT\.app|Codex Framework\.framework|Claude\.app/.test(text)) return '';
57
+ const executable = text.trim().split(/\s+/)[0] || '';
58
+ const name = executable.split('/').pop().toLowerCase();
59
+ if (name === 'cursor-agent') return 'cursor';
60
+ if (/^codex(?:-|$)/.test(name)) return 'codex';
61
+ if (name === 'grok') return 'grok';
62
+ if (name === 'devin') return 'devin';
63
+ if (name === 'droid') return 'droid';
64
+ if (name === 'agy') return 'agy';
65
+ if (name === 'claude' && /(^|\s)(?:-p|--print)(?:\s|$)/.test(text)) return 'claude';
66
+ return '';
67
+ }
68
+
69
+ function parsePsOutput(text) {
70
+ const allRows = [];
71
+ for (const line of String(text || '').split(/\r?\n/)) {
72
+ const parts = line.trim().split(/\s+/);
73
+ if (parts.length < 8) continue;
74
+ const pid = Number(parts[0]);
75
+ const ppid = Number(parts[1]);
76
+ const command = parts.slice(7).join(' ');
77
+ if (!Number.isInteger(pid) || pid <= 0) continue;
78
+ const started = Date.parse(parts.slice(2, 7).join(' '));
79
+ allRows.push({
80
+ pid,
81
+ ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : null,
82
+ command,
83
+ started_at: Number.isFinite(started) ? new Date(started).toISOString() : null,
84
+ });
85
+ }
86
+ const byPid = new Map(allRows.map((row) => [row.pid, row]));
87
+ const engineRows = allRows
88
+ .map((row) => ({ ...row, engine: engineForCommand(row.command) }))
89
+ .filter((row) => row.engine);
90
+ const parentPids = new Set(engineRows.map((row) => row.ppid).filter(Boolean));
91
+ return engineRows
92
+ .filter((row) => !parentPids.has(row.pid))
93
+ .map((row) => {
94
+ const ancestorPids = [];
95
+ let parent = row.ppid;
96
+ while (parent && !ancestorPids.includes(parent) && ancestorPids.length < 64) {
97
+ ancestorPids.push(parent);
98
+ parent = byPid.get(parent)?.ppid || null;
99
+ }
100
+ return { ...row, ancestor_pids: ancestorPids };
101
+ });
102
+ }
103
+
104
+ function normalizeProcesses(processes) {
105
+ const byPid = new Map();
106
+ for (const row of Array.isArray(processes) ? processes : []) {
107
+ const pid = Number(row?.pid);
108
+ const engine = normalizeEngine(row?.engine) || engineForCommand(row?.command);
109
+ if (!Number.isInteger(pid) || pid <= 0 || !engine) continue;
110
+ byPid.set(pid, {
111
+ pid,
112
+ ppid: Number(row?.ppid) || null,
113
+ engine,
114
+ command: String(row?.command || ''),
115
+ started_at: isoTimestamp(row?.started_at || row?.start || row?.at),
116
+ ancestor_pids: (Array.isArray(row?.ancestor_pids) ? row.ancestor_pids : [])
117
+ .map(Number)
118
+ .filter((value) => Number.isInteger(value) && value > 0),
119
+ });
120
+ }
121
+ return [...byPid.values()].sort((left, right) => left.pid - right.pid);
122
+ }
123
+
124
+ function taskRef(task) {
125
+ return String(task?.display_id || task?.legacy_ref || task?.id || '').trim();
126
+ }
127
+
128
+ function taskOwner(task) {
129
+ return String(task?.claimed_by || task?.assigned_to || task?.metadata?.assigned_to || '').trim();
130
+ }
131
+
132
+ function taskActivity(task) {
133
+ return task?.updated_at || task?.claimed_at || task?.created_at || null;
134
+ }
135
+
136
+ function receiptTaskRefs(receipt) {
137
+ const values = [receipt?.task_id, receipt?.task];
138
+ if (Array.isArray(receipt?.tasks)) values.push(...receipt.tasks);
139
+ if (Array.isArray(receipt?.task_ids)) values.push(...receipt.task_ids);
140
+ if (Array.isArray(receipt?.results)) values.push(...receipt.results.map((row) => row?.task || row?.task_id));
141
+ return [...new Set(values.map((value) => {
142
+ if (value && typeof value === 'object') return value.display_id || value.id || value.task;
143
+ return value;
144
+ }).map((value) => String(value || '').trim()).filter(Boolean))];
145
+ }
146
+
147
+ function receiptEngine(receipt) {
148
+ return normalizeEngine(
149
+ receipt?.engine
150
+ || receipt?.engines?.[0]
151
+ || receipt?.results?.find((row) => row?.engine)?.engine,
152
+ );
153
+ }
154
+
155
+ function receiptStatus(receipt) {
156
+ return String(receipt?.status || '').trim().toLowerCase();
157
+ }
158
+
159
+ function isRunningReceipt(receipt) {
160
+ return !receipt?.finished_at && RUNNING_RECEIPT_STATUSES.has(receiptStatus(receipt));
161
+ }
162
+
163
+ function isFinishedReceipt(receipt) {
164
+ if (!receipt || isRunningReceipt(receipt)) return false;
165
+ return Boolean(receipt.finished_at || TERMINAL_RECEIPT_STATUSES.has(receiptStatus(receipt)));
166
+ }
167
+
168
+ function receiptStartedAt(receipt, fallback) {
169
+ return receipt?.started_at || receipt?.at || receipt?.created_at || fallback || null;
170
+ }
171
+
172
+ function receiptFinishedAt(receipt, fallback) {
173
+ return receipt?.finished_at || receipt?.completed_at || receipt?.updated_at || fallback || null;
174
+ }
175
+
176
+ function finalResult(receipt) {
177
+ if (typeof receipt?.result === 'string') return receipt.result.trim();
178
+ if (receipt?.result && typeof receipt.result === 'object') {
179
+ const kind = String(receipt.result.kind || '').trim();
180
+ if (typeof receipt.result.passed === 'boolean') return `${kind || 'result'} ${receipt.result.passed ? 'passed' : 'failed'}`;
181
+ if (kind) return kind;
182
+ }
183
+ if (receipt?.summary && typeof receipt.summary === 'object') {
184
+ const answered = Number(receipt.summary.answered) || 0;
185
+ const failed = Number(receipt.summary.failed) || 0;
186
+ if (answered || failed) return `${answered} answered, ${failed} failed`;
187
+ }
188
+ return receiptStatus(receipt) || 'finished';
189
+ }
190
+
191
+ function taskLookup(tasks) {
192
+ const byRef = new Map();
193
+ for (const task of tasks) {
194
+ for (const ref of [task?.id, task?.display_id, task?.legacy_ref]) {
195
+ const key = String(ref || '').trim().toLowerCase();
196
+ if (key) byRef.set(key, task);
197
+ }
198
+ }
199
+ return byRef;
200
+ }
201
+
202
+ function firstTaskForRefs(refs, byRef) {
203
+ for (const ref of refs) {
204
+ const task = byRef.get(String(ref).toLowerCase());
205
+ if (task) return task;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ function rowTask(refs, task) {
211
+ return taskRef(task) || refs[0] || null;
212
+ }
213
+
214
+ function baseRow({ member, task, title, engine, source, at, nowMs }) {
215
+ return {
216
+ member: member || null,
217
+ task: task || null,
218
+ title: title || null,
219
+ engine: engine || null,
220
+ source,
221
+ at: isoTimestamp(at),
222
+ age_seconds: ageSeconds(at, nowMs),
223
+ };
224
+ }
225
+
226
+ function buildWorkforcePresence(input = {}) {
227
+ const nowMs = timestampMs(input.nowMs ?? input.now ?? Date.now()) || Date.now();
228
+ const staleAfterMs = Number(input.staleAfterMs) > 0 ? Number(input.staleAfterMs) : DEFAULT_STALE_AFTER_MS;
229
+ const tasks = (Array.isArray(input.tasks) ? input.tasks : [])
230
+ .filter((task) => ACTIVE_TASK_STATUSES.has(String(task?.status || '').toLowerCase()) && taskOwner(task));
231
+ const missions = (Array.isArray(input.missions) ? input.missions : [])
232
+ .filter((mission) => ACTIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase()));
233
+ const receipts = Array.isArray(input.receipts) ? input.receipts : [];
234
+ const processes = normalizeProcesses(input.processes);
235
+ const byTaskRef = taskLookup(tasks);
236
+ const usedPids = new Set();
237
+ const representedTasks = new Set();
238
+ const working = [];
239
+ const waiting = [];
240
+ const done = [];
241
+ const stale = [];
242
+
243
+ const claimProcess = (engine, pid, expectedStart) => {
244
+ const wantedPid = Number(pid);
245
+ if (Number.isInteger(wantedPid) && wantedPid > 0) {
246
+ const exact = processes.find((row) => (
247
+ row.pid === wantedPid || row.ancestor_pids.includes(wantedPid)
248
+ ) && row.engine === engine && !usedPids.has(row.pid) && (
249
+ !timestampMs(expectedStart)
250
+ || !timestampMs(row.started_at)
251
+ || Math.abs(timestampMs(row.started_at) - timestampMs(expectedStart)) <= PROCESS_START_TOLERANCE_MS
252
+ ));
253
+ if (exact) usedPids.add(exact.pid);
254
+ return exact || null;
255
+ }
256
+ return null;
257
+ };
258
+ const workTokens = (value) => new Set(
259
+ (String(value || '').toLowerCase().match(/[a-z][a-z0-9]{4,}/g) || [])
260
+ .filter((token) => !WORK_TOKEN_STOP_WORDS.has(token)),
261
+ );
262
+ const processMatchesWork = (row, refs, title) => {
263
+ const command = String(row.command || '').toLowerCase();
264
+ if (refs.some((ref) => command.includes(String(ref).toLowerCase()))) return true;
265
+ const titleTokens = workTokens(title);
266
+ const commandTokens = workTokens(command);
267
+ return [...titleTokens].some((token) => commandTokens.has(token));
268
+ };
269
+ const claimMatchingProcess = (engine, refs, title) => {
270
+ const candidate = processes.find((row) => (
271
+ (!engine || row.engine === engine)
272
+ && !usedPids.has(row.pid)
273
+ && processMatchesWork(row, refs, title)
274
+ ));
275
+ if (candidate) usedPids.add(candidate.pid);
276
+ return candidate || null;
277
+ };
278
+
279
+ for (const entry of receipts) {
280
+ const receipt = entry?.receipt || entry;
281
+ const engine = receiptEngine(receipt);
282
+ if (!engine) continue;
283
+ const refs = receiptTaskRefs(receipt);
284
+ const task = firstTaskForRefs(refs, byTaskRef);
285
+ const member = String(receipt?.member || receipt?.owner || receipt?.actor || taskOwner(task) || '').trim();
286
+ const taskValue = rowTask(refs, task);
287
+ const startedAt = receiptStartedAt(receipt, entry?.mtimeMs);
288
+ const common = baseRow({
289
+ member,
290
+ task: taskValue,
291
+ title: task?.title || receipt?.objective || '',
292
+ engine,
293
+ source: 'receipt',
294
+ at: startedAt,
295
+ nowMs,
296
+ });
297
+ if (isRunningReceipt(receipt)) {
298
+ const processRow = claimProcess(engine, receipt.pid, startedAt)
299
+ || claimMatchingProcess(engine, refs, task?.title || receipt?.objective || '');
300
+ const row = {
301
+ ...common,
302
+ pid: processRow?.pid || Number(receipt.pid) || null,
303
+ receipt: entry?.name || receipt?.receipt || null,
304
+ };
305
+ if (processRow) working.push(row);
306
+ else if (Number(receipt.pid) > 0 || nowMs - timestampMs(startedAt) > staleAfterMs) {
307
+ stale.push({ ...row, reason: 'run has no live process' });
308
+ } else {
309
+ waiting.push({ ...row, reason: 'run has not started a local process' });
310
+ }
311
+ if (taskValue) representedTasks.add(String(taskValue).toLowerCase());
312
+ continue;
313
+ }
314
+ if (isFinishedReceipt(receipt)) {
315
+ done.push({
316
+ ...common,
317
+ at: isoTimestamp(receiptFinishedAt(receipt, entry?.mtimeMs)),
318
+ age_seconds: ageSeconds(receiptFinishedAt(receipt, entry?.mtimeMs), nowMs),
319
+ run_status: receiptStatus(receipt) || 'finished',
320
+ result: finalResult(receipt),
321
+ receipt: entry?.name || receipt?.receipt || null,
322
+ });
323
+ }
324
+ }
325
+
326
+ for (const mission of missions) {
327
+ const refs = Array.isArray(mission?.task_ids) ? mission.task_ids.map(String) : [];
328
+ if (refs.some((ref) => representedTasks.has(ref.toLowerCase()))) continue;
329
+ const task = firstTaskForRefs(refs, byTaskRef);
330
+ const engine = normalizeEngine(mission?.runner || mission?.engine || mission?.executed_by);
331
+ const member = String(mission?.owner || mission?.member || taskOwner(task) || '').trim();
332
+ const at = mission?.last_tick_at || mission?.updated_at || mission?.created_at;
333
+ const common = baseRow({
334
+ member,
335
+ task: rowTask(refs, task),
336
+ title: task?.title || mission?.objective || mission?.name || '',
337
+ engine,
338
+ source: 'mission',
339
+ at,
340
+ nowMs,
341
+ });
342
+ const processRow = engine
343
+ ? claimProcess(engine, mission?.pid, at) || claimMatchingProcess(engine, refs, common.title)
344
+ : null;
345
+ if (processRow) working.push({ ...common, pid: processRow.pid, mission: mission?.id || null });
346
+ else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'mission has no live process', mission: mission?.id || null });
347
+ else waiting.push({ ...common, reason: 'mission is waiting for a local process', mission: mission?.id || null });
348
+ for (const ref of refs) representedTasks.add(ref.toLowerCase());
349
+ }
350
+
351
+ for (const task of tasks) {
352
+ const ref = taskRef(task);
353
+ if (representedTasks.has(ref.toLowerCase())) continue;
354
+ const engine = normalizeEngine(task?.executed_by || task?.metadata?.executed_by || task?.metadata?.engine);
355
+ const at = taskActivity(task);
356
+ const common = baseRow({
357
+ member: taskOwner(task),
358
+ task: ref,
359
+ title: task?.title || '',
360
+ engine,
361
+ source: 'task',
362
+ at,
363
+ nowMs,
364
+ });
365
+ const processRow = claimProcess(engine, task?.pid || task?.metadata?.pid, at)
366
+ || claimMatchingProcess(engine, [ref], '');
367
+ if (processRow) working.push({ ...common, engine: processRow.engine, pid: processRow.pid });
368
+ else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'claim is older than seven days with no live process' });
369
+ else waiting.push({ ...common, reason: 'claim has no live process yet' });
370
+ }
371
+
372
+ const unowned = processes
373
+ .filter((row) => !usedPids.has(row.pid))
374
+ .map((row) => ({
375
+ engine: row.engine,
376
+ pid: row.pid,
377
+ command: row.command,
378
+ started_at: row.started_at,
379
+ age_seconds: ageSeconds(row.started_at, nowMs),
380
+ reason: 'no matching claim, mission, or run receipt',
381
+ }));
382
+
383
+ const newestFirst = (left, right) => timestampMs(right.at || right.started_at) - timestampMs(left.at || left.started_at);
384
+ working.sort(newestFirst);
385
+ waiting.sort(newestFirst);
386
+ done.sort(newestFirst);
387
+ stale.sort(newestFirst);
388
+
389
+ return {
390
+ schema: 'atris.workforce_presence.v1',
391
+ generated_at: new Date(nowMs).toISOString(),
392
+ stale_after_seconds: Math.round(staleAfterMs / 1000),
393
+ totals: {
394
+ working: working.length,
395
+ waiting: waiting.length,
396
+ done: done.length,
397
+ stale: stale.length,
398
+ unowned: unowned.length,
399
+ },
400
+ working,
401
+ waiting,
402
+ done,
403
+ stale,
404
+ unowned,
405
+ };
406
+ }
407
+
408
+ function formatAge(seconds) {
409
+ if (seconds == null) return 'age unknown';
410
+ if (seconds < 60) return `${seconds}s`;
411
+ const minutes = Math.floor(seconds / 60);
412
+ if (minutes < 60) return `${minutes}m`;
413
+ const hours = Math.floor(minutes / 60);
414
+ if (hours < 48) return `${hours}h`;
415
+ return `${Math.floor(hours / 24)}d`;
416
+ }
417
+
418
+ function rowSubject(row) {
419
+ const member = row.member || 'unassigned';
420
+ const task = row.task || row.title || 'local work';
421
+ return `${member}: ${row.engine || 'unknown engine'} on ${task}`;
422
+ }
423
+
424
+ function renderWorkforcePresence(presence) {
425
+ const lines = [];
426
+ const section = (name, rows, render, limit = rows.length) => {
427
+ lines.push(`${name}:`);
428
+ if (!rows.length) lines.push(' none');
429
+ else rows.slice(0, limit).forEach((row) => lines.push(` ${render(row)}`));
430
+ if (rows.length > limit) lines.push(` ${rows.length - limit} more; clear finished runs with atris who --clear`);
431
+ };
432
+ section('working', presence.working, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}, pid ${row.pid || '?'})`);
433
+ section('waiting', presence.waiting, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
434
+ section('done', presence.done, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.run_status}: ${row.result}`, 10);
435
+ section('stale', presence.stale, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
436
+ section('unowned', presence.unowned, (row) => `${row.engine} pid ${row.pid} (${formatAge(row.age_seconds)}), ${row.reason}`);
437
+ const totals = presence.totals;
438
+ lines.push(`totals: ${totals.working} working, ${totals.waiting} waiting, ${totals.done} done, ${totals.stale} stale, ${totals.unowned} unowned`);
439
+ return lines.join('\n');
440
+ }
441
+
442
+ module.exports = {
443
+ buildWorkforcePresence,
444
+ isFinishedReceipt,
445
+ parsePsOutput,
446
+ receiptEngine,
447
+ renderWorkforcePresence,
448
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.47.0",
3
+ "version": "3.48.1",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "commands/",
15
15
  "decks/",
16
16
  "scripts/agent_worktree.py",
17
+ "scripts/det/",
17
18
  "utils/",
18
19
  "lib/",
19
20
  "templates/",
@@ -0,0 +1,162 @@
1
+ # Deterministic task scripts
2
+
3
+ Small, zero-dependency scripts for jobs LLMs get asked to do constantly but that
4
+ are actually deterministic: extracting, converting, counting, reformatting text,
5
+ and drafting commit/PR text from git. A cheap model (or a human, or a cron) runs
6
+ the script instead of spending tokens and risking a wrong guess. The output is
7
+ exact and reproducible, not inferred.
8
+
9
+ ## Pick a tool (one read)
10
+
11
+ Find the row that matches the ask, run the command. All paths are under
12
+ `node scripts/det/`. Add `--json` to any script for structured output.
13
+
14
+ | If the ask is… | Run | Modes / notes |
15
+ |----------------|-----|---------------|
16
+ | pull links / emails / code / numbers out of text | `extract.js <mode> < in` | `urls` `emails` `code` `numbers` `ipv4` `hashtags` |
17
+ | reformat / validate / flatten JSON, or JSON to CSV | `json.js <mode> < in` | `pretty` `min` `validate` `keys` `csv` |
18
+ | dedupe / sort / count / slugify / trim lines | `text.js <mode> < in` | `dedupe` `sort` `rsort` `count` `slug` `trim` |
19
+ | base64 / hex encode-decode, sha256 / sha1 / md5 hash | `hash.js <mode> < in` | `b64` `b64d` `sha256` `sha1` `md5` `hexenc` `hexdec` |
20
+ | convert a timestamp, or get the weekday (all UTC) | `date.js <mode> < in` | `iso` `epoch` `epochms` `weekday` |
21
+ | write a commit message | `git add -A && commit-msg.js` | reads the staged diff |
22
+ | summarize what changed since a release | `changelog.js [ref]` | reads git log |
23
+ | write a PR description for this branch | `pr-description.js [base]` | reads the branch diff |
24
+
25
+ If no row matches, do the task normally. This library grows one verified script
26
+ at a time; never add one without a self-test.
27
+
28
+ ## How to call
29
+
30
+ The first five read stdin, write stdout, exit 0 on success and non-zero on bad
31
+ input. The last three read git directly (their input is the repo, not stdin).
32
+
33
+ You can call any script directly, or use the dispatcher as a discovery front door:
34
+
35
+ ```bash
36
+ node scripts/det/det.js # print the catalog (all 8 tools)
37
+ node scripts/det/det.js <script> <mode> < input # route stdin through it
38
+ ```
39
+
40
+ `det.js` lists every tool: the five stdin scripts it can route, plus the three
41
+ git-facing scripts (which it points you to run directly, since their input is the
42
+ repo). The stdin catalog is derived from the scripts' own exports, so it can never
43
+ drift from what actually runs. Trust the output; do not "improve" it.
44
+
45
+ ## stdin scripts
46
+
47
+ ### extract.js
48
+
49
+ ```bash
50
+ cat page.html | node scripts/det/extract.js urls
51
+ node scripts/det/extract.js emails < contacts.txt
52
+ node scripts/det/extract.js code < README.md # fenced blocks, contents only
53
+ node scripts/det/extract.js --json urls < page.html # JSON array
54
+ ```
55
+
56
+ Duplicates removed, first-seen order preserved. Unknown mode exits 2.
57
+
58
+ ### json.js
59
+
60
+ ```bash
61
+ cat data.json | node scripts/det/json.js pretty # 2-space indent
62
+ node scripts/det/json.js min < data.json # minified
63
+ node scripts/det/json.js validate < data.json # "valid" or errors (exit 2)
64
+ node scripts/det/json.js keys < data.json # top-level keys
65
+ node scripts/det/json.js csv < array.json # array of objects -> RFC-4180 CSV
66
+ ```
67
+
68
+ `csv` handles the escaping LLMs get wrong: fields with commas or quotes are
69
+ quoted, inner quotes doubled. Columns follow first-seen key order across rows.
70
+
71
+ ### text.js
72
+
73
+ ```bash
74
+ cat list.txt | node scripts/det/text.js dedupe # drop dup lines, keep first order
75
+ node scripts/det/text.js sort < list.txt # byte-order sort (rsort = reverse)
76
+ node scripts/det/text.js count < list.txt # lines / words / chars (tab-separated)
77
+ node scripts/det/text.js slug < titles.txt # each line -> url slug (accents folded)
78
+ node scripts/det/text.js trim < messy.txt # strip trailing ws, drop blank lines
79
+ ```
80
+
81
+ `count` is exact, no more eyeballed line/word totals. `slug` folds accents
82
+ (Café to cafe) so slugs are stable across inputs.
83
+
84
+ ### hash.js
85
+
86
+ ```bash
87
+ printf 'hi' | node scripts/det/hash.js b64 # base64 encode (b64d decodes)
88
+ node scripts/det/hash.js sha256 < file.txt # real hex sha256 (sha1, md5 too)
89
+ node scripts/det/hash.js hexenc < file.txt # raw <-> hex (hexdec reverses)
90
+ ```
91
+
92
+ A single trailing newline is stripped before encoding/hashing, so `echo hi` and
93
+ `printf 'hi'` give the same result. These are real crypto digests, not the
94
+ plausible-looking fakes an LLM emits.
95
+
96
+ ### date.js
97
+
98
+ ```bash
99
+ echo 1700000000 | node scripts/det/date.js iso # epoch (s or ms) -> ISO UTC
100
+ echo 2026-07-07 | node scripts/det/date.js epoch # date -> epoch seconds (epochms for ms)
101
+ echo 2026-07-07 | node scripts/det/date.js weekday # -> Tuesday
102
+ ```
103
+
104
+ Everything is UTC and machine-independent: epoch auto-detects seconds vs ms, and
105
+ a bare date string with no timezone is pinned to UTC instead of guessing local.
106
+
107
+ ## git-facing scripts
108
+
109
+ These replace LLM *generation*, not just data munging. They read git directly, so
110
+ there is no stdin and they sit outside the dispatcher catalog.
111
+
112
+ ### commit-msg.js
113
+
114
+ ```bash
115
+ git add -A && node scripts/det/commit-msg.js # print the drafted message
116
+ node scripts/det/commit-msg.js --json # {type,scope,subject,body,...}
117
+ ```
118
+
119
+ Type and scope come from the changed paths (`docs`/`test`/`chore`/`feat`/`fix`,
120
+ scope = deepest common dir); the body is exact diff stats. No intent-guessing.
121
+ Multi-file changes name the lead file (the added one, else the biggest churn), as
122
+ `add changelog.js (+2 more)`, never the vague `update 3 files`.
123
+
124
+ ### changelog.js
125
+
126
+ ```bash
127
+ node scripts/det/changelog.js # since the last tag -> markdown
128
+ node scripts/det/changelog.js v3.34.0 # since a specific ref
129
+ node scripts/det/changelog.js v3.34.0 HEAD # explicit range
130
+ node scripts/det/changelog.js --json # {sections,counts,breaking,...}
131
+ ```
132
+
133
+ Sections, order, and bullets come straight from the commit subjects grouped by
134
+ Conventional-Commits type (`feat` to Features, `fix` to Fixes, ...); `type!:`
135
+ commits surface under BREAKING CHANGES. Subjects that don't match the header
136
+ grammar land in "Other" so nothing is dropped. No paraphrase, no invented or
137
+ missing entries.
138
+
139
+ ### pr-description.js
140
+
141
+ ```bash
142
+ node scripts/det/pr-description.js # diff origin/master...HEAD -> markdown
143
+ node scripts/det/pr-description.js origin/main # different base branch
144
+ node scripts/det/pr-description.js origin/main HEAD # explicit base + head
145
+ node scripts/det/pr-description.js --json # {title,summary,testPlan,...}
146
+ ```
147
+
148
+ Title comes from the commits (one commit -> its subject; many -> dominant type
149
+ plus lead file); the summary is one bullet per changed area with counts and
150
+ churn; the test-plan lists the touched test files plus one check per non-test
151
+ area. Every line is backed by a real change in the diff, no invented rationale.
152
+
153
+ ## Verifying the library
154
+
155
+ ```bash
156
+ node scripts/det/test.js # runs every script against known input/output
157
+ ```
158
+
159
+ Runs fast, no deps, CI-safe. A script is not "done" until it appears here with a
160
+ passing test. This suite is also gated by the repo's `npm test` via
161
+ `test/det.test.js`, which runs it as a subprocess, so the library cannot silently
162
+ rot in CI.