atris 3.51.0 → 3.53.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.
- package/atris/skills/engines/SKILL.md +8 -7
- package/atris/skills/youtube/SKILL.md +8 -8
- package/ax +592 -91
- package/bin/atris.js +17 -5
- package/commands/computer.js +2 -2
- package/commands/mission.js +52 -24
- package/commands/now.js +10 -3
- package/commands/radar.js +65 -33
- package/commands/youtube.js +206 -23
- package/lib/auto-accept-certified.js +2 -1
- package/lib/engine-ask.js +11 -3
- package/lib/engine-registry.js +32 -7
- package/lib/mission-ledger-compact.js +127 -0
- package/lib/mission-runtime-loop.js +30 -2
- package/lib/next-moves.js +34 -34
- package/lib/permission-grants.js +10 -1
- package/lib/runner-command.js +5 -4
- package/lib/task-db.js +18 -2
- package/lib/task-proof.js +6 -1
- package/lib/task-receipt.js +5 -1
- package/package.json +2 -1
- package/scripts/outbound-artifact-gate.js +227 -0
package/lib/next-moves.js
CHANGED
|
@@ -120,12 +120,17 @@ function readRoadmapOpenItems(root) {
|
|
|
120
120
|
}));
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
// One parse of tasks.projection.json, shared by every reader that needs the
|
|
124
|
+
// task list, so a single nextMoves() call does not re-read the same big file.
|
|
125
|
+
function readTasksProjection(root) {
|
|
124
126
|
const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
|
|
125
127
|
if (!text) return [];
|
|
126
128
|
let proj;
|
|
127
129
|
try { proj = JSON.parse(text); } catch { return []; }
|
|
128
|
-
|
|
130
|
+
return Array.isArray(proj && proj.tasks) ? proj.tasks : [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readActiveTasks(root, tasks = readTasksProjection(root)) {
|
|
129
134
|
return tasks
|
|
130
135
|
.filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
|
|
131
136
|
.filter((t) => !isInternalNextMoveTask(t))
|
|
@@ -147,12 +152,7 @@ function isInternalNextMoveTask(task) {
|
|
|
147
152
|
return tags.includes('agent-xp') || /^mission xp\s*:/i.test(title);
|
|
148
153
|
}
|
|
149
154
|
|
|
150
|
-
function readHandledTaskTitles(root) {
|
|
151
|
-
const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
|
|
152
|
-
if (!text) return [];
|
|
153
|
-
let proj;
|
|
154
|
-
try { proj = JSON.parse(text); } catch { return []; }
|
|
155
|
-
const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
|
|
155
|
+
function readHandledTaskTitles(root, tasks = readTasksProjection(root)) {
|
|
156
156
|
return tasks
|
|
157
157
|
.filter((t) => t && t.title && ['review', 'done'].includes(String(t.status || '').toLowerCase()))
|
|
158
158
|
.map((t) => String(t.title).trim())
|
|
@@ -297,8 +297,8 @@ function missionShortLabel(mission) {
|
|
|
297
297
|
return shortRecordLabel(mission, missionVisibleTitle(mission), { wordLimit: 3 });
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
function readActionableMissionCards(root) {
|
|
301
|
-
return Array.from(
|
|
300
|
+
function readActionableMissionCards(root, missions = latestMissionRecords(root)) {
|
|
301
|
+
return Array.from(missions.values())
|
|
302
302
|
.filter((mission) => {
|
|
303
303
|
const status = String(mission?.status || '').toLowerCase();
|
|
304
304
|
if (['complete', 'completed', 'stopped', 'paused'].includes(status)) return false;
|
|
@@ -324,8 +324,8 @@ function readActionableMissionCards(root) {
|
|
|
324
324
|
});
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
function readActiveMissions(root) {
|
|
328
|
-
return Array.from(
|
|
327
|
+
function readActiveMissions(root, missions = latestMissionRecords(root)) {
|
|
328
|
+
return Array.from(missions.values())
|
|
329
329
|
.filter((m) => m && missionVisibleTitle(m) && !['complete', 'stopped', 'paused'].includes(String(m.status || '').toLowerCase()))
|
|
330
330
|
.map((m) => ({
|
|
331
331
|
title: missionVisibleTitle(m),
|
|
@@ -397,9 +397,8 @@ function wishHasReview(wish) {
|
|
|
397
397
|
return Boolean(wish && (wish.reviewed || (Array.isArray(wish.reviews) && wish.reviews.length)));
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
function readUnreviewedShippedWishCards(root) {
|
|
400
|
+
function readUnreviewedShippedWishCards(root, missions = latestMissionRecords(root)) {
|
|
401
401
|
const latest = latestWishEventMap(root);
|
|
402
|
-
const missions = latestMissionRecords(root);
|
|
403
402
|
return readWishes(root)
|
|
404
403
|
.filter((wish) => {
|
|
405
404
|
if (!wish || !wish.id || wishHasReview(wish)) return false;
|
|
@@ -430,12 +429,7 @@ function readUnreviewedShippedWishCards(root) {
|
|
|
430
429
|
});
|
|
431
430
|
}
|
|
432
431
|
|
|
433
|
-
function activeEndgameTaskCount(root) {
|
|
434
|
-
const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
|
|
435
|
-
if (!text) return 0;
|
|
436
|
-
let proj;
|
|
437
|
-
try { proj = JSON.parse(text); } catch { return 0; }
|
|
438
|
-
const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
|
|
432
|
+
function activeEndgameTaskCount(root, tasks = readTasksProjection(root)) {
|
|
439
433
|
return tasks.filter((t) => {
|
|
440
434
|
if (!t) return false;
|
|
441
435
|
const status = String(t.status || '').toLowerCase();
|
|
@@ -444,10 +438,10 @@ function activeEndgameTaskCount(root) {
|
|
|
444
438
|
}).length;
|
|
445
439
|
}
|
|
446
440
|
|
|
447
|
-
function todoKeepsEndgameActive(root) {
|
|
441
|
+
function todoKeepsEndgameActive(root, tasks = readTasksProjection(root)) {
|
|
448
442
|
const text = safeRead(path.join(root, 'atris', 'TODO.md'));
|
|
449
443
|
if (!text) return true;
|
|
450
|
-
if (activeEndgameTaskCount(root) > 0) return true;
|
|
444
|
+
if (activeEndgameTaskCount(root, tasks) > 0) return true;
|
|
451
445
|
|
|
452
446
|
let sawEndgameHeader = false;
|
|
453
447
|
let section = '';
|
|
@@ -464,8 +458,8 @@ function todoKeepsEndgameActive(root) {
|
|
|
464
458
|
return !sawEndgameHeader;
|
|
465
459
|
}
|
|
466
460
|
|
|
467
|
-
function readEndgameMove(root) {
|
|
468
|
-
if (!todoKeepsEndgameActive(root)) return [];
|
|
461
|
+
function readEndgameMove(root, tasks = readTasksProjection(root)) {
|
|
462
|
+
if (!todoKeepsEndgameActive(root, tasks)) return [];
|
|
469
463
|
let state = null;
|
|
470
464
|
try { state = JSON.parse(safeRead(path.join(root, 'atris', 'brain', 'state.json'))); } catch { state = null; }
|
|
471
465
|
const horizon = String(state?.endgame?.horizon || '').trim();
|
|
@@ -508,17 +502,21 @@ function todayInboxItems(root) {
|
|
|
508
502
|
return inboxItemsFrom(safeRead(file));
|
|
509
503
|
}
|
|
510
504
|
|
|
511
|
-
|
|
505
|
+
// Parse each shared big file (tasks projection, missions log) exactly once per
|
|
506
|
+
// call and thread the result to every reader that needs it.
|
|
507
|
+
function gatherCandidates(root = process.cwd(), shared = {}) {
|
|
508
|
+
const tasks = shared.tasks || readTasksProjection(root);
|
|
509
|
+
const missions = shared.missions || latestMissionRecords(root);
|
|
512
510
|
return [
|
|
513
511
|
...readWaitingWishCards(root),
|
|
514
|
-
...readActionableMissionCards(root),
|
|
512
|
+
...readActionableMissionCards(root, missions),
|
|
515
513
|
...readDreamCards(root),
|
|
516
514
|
...readRoadmapOpenItems(root),
|
|
517
|
-
...readActiveMissions(root),
|
|
518
|
-
...readEndgameMove(root),
|
|
519
|
-
...readActiveTasks(root),
|
|
515
|
+
...readActiveMissions(root, missions),
|
|
516
|
+
...readEndgameMove(root, tasks),
|
|
517
|
+
...readActiveTasks(root, tasks),
|
|
520
518
|
...latestInboxItems(root),
|
|
521
|
-
...readUnreviewedShippedWishCards(root),
|
|
519
|
+
...readUnreviewedShippedWishCards(root, missions),
|
|
522
520
|
];
|
|
523
521
|
}
|
|
524
522
|
|
|
@@ -595,10 +593,11 @@ function parkNextCard(root, card, stamp) {
|
|
|
595
593
|
|
|
596
594
|
function nextCards(root = process.cwd(), limit = 1, options = {}) {
|
|
597
595
|
const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
|
|
598
|
-
const
|
|
596
|
+
const tasks = readTasksProjection(root);
|
|
597
|
+
const handledTaskTitles = readHandledTaskTitles(root, tasks);
|
|
599
598
|
const parkedIds = options.includeParked ? [] : parkedNextIds(root);
|
|
600
599
|
const skipIds = Array.isArray(options.skipIds) ? options.skipIds : [];
|
|
601
|
-
return pickNextMoves(gatherCandidates(root), {
|
|
600
|
+
return pickNextMoves(gatherCandidates(root, { tasks }), {
|
|
602
601
|
limit,
|
|
603
602
|
killedIds: [...killedIds, ...parkedIds, ...skipIds],
|
|
604
603
|
killedTitles,
|
|
@@ -836,8 +835,9 @@ function pickRoadmapSeed(root = process.cwd()) {
|
|
|
836
835
|
// so the ranking inputs live in one place.
|
|
837
836
|
function nextMoves(root = process.cwd(), limit = 3) {
|
|
838
837
|
const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
|
|
839
|
-
const
|
|
840
|
-
const
|
|
838
|
+
const tasks = readTasksProjection(root);
|
|
839
|
+
const handledTaskTitles = readHandledTaskTitles(root, tasks);
|
|
840
|
+
const picked = pickNextMoves(gatherCandidates(root, { tasks }), { limit, killedIds, killedTitles, approvedIds, approvedTitles, handledTaskTitles });
|
|
841
841
|
return addMissionOnlyFallback(picked, limit, root);
|
|
842
842
|
}
|
|
843
843
|
|
package/lib/permission-grants.js
CHANGED
|
@@ -119,7 +119,15 @@ function addGrant({ command, workspaceRoot, createdVia = 'cli', expiresInDays =
|
|
|
119
119
|
|
|
120
120
|
function revokeGrant(grantId, file) {
|
|
121
121
|
const store = loadGrants(file);
|
|
122
|
-
const
|
|
122
|
+
const ref = String(grantId || '');
|
|
123
|
+
let grant = store.grants.find(g => g.grant_id === ref);
|
|
124
|
+
if (!grant && ref) {
|
|
125
|
+
// Prefix refs are a convenience, but an ambiguous prefix must never
|
|
126
|
+
// revoke an arbitrary grant — require exactly one match.
|
|
127
|
+
const matches = store.grants.filter(g => String(g.grant_id).startsWith(ref));
|
|
128
|
+
if (matches.length > 1) return { ok: false, reason: `"${ref}" matches ${matches.length} grants; use more characters` };
|
|
129
|
+
grant = matches[0];
|
|
130
|
+
}
|
|
123
131
|
if (!grant) return { ok: false, reason: 'grant not found' };
|
|
124
132
|
grant.status = 'revoked';
|
|
125
133
|
grant.sync = { ...(grant.sync || {}), revoked_at: new Date().toISOString() };
|
|
@@ -283,5 +291,6 @@ module.exports = {
|
|
|
283
291
|
revokeGrant,
|
|
284
292
|
matchGrant,
|
|
285
293
|
recordUse,
|
|
294
|
+
commandIsGrantable,
|
|
286
295
|
syncGrants,
|
|
287
296
|
};
|
package/lib/runner-command.js
CHANGED
|
@@ -67,10 +67,10 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
67
67
|
model: 'grok-4.6',
|
|
68
68
|
commandTemplate: '{bin} --always-approve -p {prompt}',
|
|
69
69
|
}),
|
|
70
|
-
|
|
71
|
-
bin: '
|
|
72
|
-
model: '',
|
|
73
|
-
commandTemplate: '{bin}
|
|
70
|
+
agy: Object.freeze({
|
|
71
|
+
bin: 'agy',
|
|
72
|
+
model: 'gemini-3.7-flash-high',
|
|
73
|
+
commandTemplate: '{bin} --mode accept-edits {modelFlag} -p {prompt}',
|
|
74
74
|
}),
|
|
75
75
|
});
|
|
76
76
|
|
|
@@ -80,6 +80,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
80
80
|
const RUNNER_PROFILE_ALIASES = Object.freeze({
|
|
81
81
|
'atris2-fast': 'atris-fast',
|
|
82
82
|
'atris-2-fast': 'atris-fast',
|
|
83
|
+
antigravity: 'agy',
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
// Back-compat surface: RUNNER_PROFILES still resolves every accepted name
|
package/lib/task-db.js
CHANGED
|
@@ -1965,11 +1965,17 @@ function selectProjectionRows(rows, { taskId, includeHistory, doneLimit }) {
|
|
|
1965
1965
|
return {
|
|
1966
1966
|
visibleRows: rows,
|
|
1967
1967
|
hiddenDoneCount: 0,
|
|
1968
|
+
hiddenArchivedCount: 0,
|
|
1968
1969
|
};
|
|
1969
1970
|
}
|
|
1970
1971
|
const visibleRows = [];
|
|
1971
1972
|
let shownDone = 0;
|
|
1972
1973
|
let hiddenDoneCount = 0;
|
|
1974
|
+
// Archived rows get the same recency cap as done rows. Without it the
|
|
1975
|
+
// projection carries every archived task forever (full events/messages) and
|
|
1976
|
+
// the file balloons; `--history` or a taskId still returns everything.
|
|
1977
|
+
let shownArchived = 0;
|
|
1978
|
+
let hiddenArchivedCount = 0;
|
|
1973
1979
|
for (const row of rows) {
|
|
1974
1980
|
if (row.status === 'done') {
|
|
1975
1981
|
if (shownDone < doneLimit) {
|
|
@@ -1980,9 +1986,18 @@ function selectProjectionRows(rows, { taskId, includeHistory, doneLimit }) {
|
|
|
1980
1986
|
}
|
|
1981
1987
|
continue;
|
|
1982
1988
|
}
|
|
1989
|
+
if (row.status === 'archived') {
|
|
1990
|
+
if (shownArchived < doneLimit) {
|
|
1991
|
+
visibleRows.push(row);
|
|
1992
|
+
shownArchived += 1;
|
|
1993
|
+
} else {
|
|
1994
|
+
hiddenArchivedCount += 1;
|
|
1995
|
+
}
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1983
1998
|
visibleRows.push(row);
|
|
1984
1999
|
}
|
|
1985
|
-
return { visibleRows, hiddenDoneCount };
|
|
2000
|
+
return { visibleRows, hiddenDoneCount, hiddenArchivedCount };
|
|
1986
2001
|
}
|
|
1987
2002
|
|
|
1988
2003
|
function taskProjection(db, {
|
|
@@ -2004,7 +2019,7 @@ function taskProjection(db, {
|
|
|
2004
2019
|
display_id: row.display_id,
|
|
2005
2020
|
legacy_ref: row.legacy_ref,
|
|
2006
2021
|
}]));
|
|
2007
|
-
const { visibleRows, hiddenDoneCount } = selectProjectionRows(rows, {
|
|
2022
|
+
const { visibleRows, hiddenDoneCount, hiddenArchivedCount } = selectProjectionRows(rows, {
|
|
2008
2023
|
taskId,
|
|
2009
2024
|
includeHistory,
|
|
2010
2025
|
doneLimit: Math.max(0, Number(doneLimit) || 0),
|
|
@@ -2028,6 +2043,7 @@ function taskProjection(db, {
|
|
|
2028
2043
|
full_task_count: rows.length,
|
|
2029
2044
|
visible_task_count: visibleRows.length,
|
|
2030
2045
|
hidden_done_count: hiddenDoneCount,
|
|
2046
|
+
hidden_archived_count: hiddenArchivedCount,
|
|
2031
2047
|
done_limit: includeHistory ? null : Math.max(0, Number(doneLimit) || 0),
|
|
2032
2048
|
event_limit: includeHistory ? null : Math.max(0, Number(eventLimit) || 0),
|
|
2033
2049
|
message_limit: includeHistory ? null : Math.max(0, Number(messageLimit) || 0),
|
package/lib/task-proof.js
CHANGED
|
@@ -80,7 +80,11 @@ function taskProofLooksExecuted(proof) {
|
|
|
80
80
|
function tailText(text, max = 400) {
|
|
81
81
|
const trimmed = String(text || '').trim();
|
|
82
82
|
if (trimmed.length <= max) return trimmed;
|
|
83
|
-
|
|
83
|
+
// Keep the head as well as the tail: failures name their cause in the first
|
|
84
|
+
// lines (missing dependency, bad import), which a tail-only cut always loses.
|
|
85
|
+
const head = Math.floor(max / 2);
|
|
86
|
+
const tail = max - head;
|
|
87
|
+
return `${trimmed.slice(0, head)}\n...\n${trimmed.slice(-tail)}`;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
// Run a verifier command and turn its real result into proof. This is the
|
|
@@ -112,6 +116,7 @@ function buildVerifiedProof(verifyCmd, baseProof = '', runner, options = {}) {
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
module.exports = {
|
|
119
|
+
tailText,
|
|
115
120
|
taskProofLooksMeaningful,
|
|
116
121
|
taskProofLooksExecuted,
|
|
117
122
|
taskProofState,
|
package/lib/task-receipt.js
CHANGED
|
@@ -14,7 +14,11 @@ const { spawnSync } = require('child_process');
|
|
|
14
14
|
function tailText(text, max = 400) {
|
|
15
15
|
const trimmed = String(text || '').trim();
|
|
16
16
|
if (trimmed.length <= max) return trimmed;
|
|
17
|
-
|
|
17
|
+
// Keep the head as well as the tail: failures name their cause in the first
|
|
18
|
+
// lines (missing dependency, bad import), which a tail-only cut always loses.
|
|
19
|
+
const head = Math.floor(max / 2);
|
|
20
|
+
const tail = max - head;
|
|
21
|
+
return `${trimmed.slice(0, head)}\n...\n${trimmed.slice(-tail)}`;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
function gitCommitHash(root) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.53.0",
|
|
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": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"cli/*.py",
|
|
14
14
|
"commands/",
|
|
15
15
|
"decks/",
|
|
16
|
+
"scripts/outbound-artifact-gate.js",
|
|
16
17
|
"scripts/agent_worktree.py",
|
|
17
18
|
"scripts/det/",
|
|
18
19
|
"utils/",
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
const HTML_TAG_RE = /<!doctype\s+html|<\/?(?:html|head|body|div|section|article|main|table|thead|tbody|tr|td|th|p|br|span|h[1-6]|ul|ol|li|style|script|a|img|strong|em)\b[^>]*>/i;
|
|
6
|
+
const ENCODED_HTML_RE = /<\/?(?:html|head|body|div|table|tr|td|p|span|h[1-6]|ul|ol|li|style|script|svg)\b/i;
|
|
7
|
+
const RENDERED_SOURCE_FENCE_RE = /```\s*(?:html|xml|jsx|tsx|css|svg|mermaid)\b/i;
|
|
8
|
+
const COACH_INTERNAL_RE = /(?:\b(?:CLI|WEB|BE)-\d+\b|\b[0-9A-HJKMNP-TV-Z]{26}\b|\bmission-[a-z0-9-]+|(?:^|\s)--[a-z][a-z-]*\b|(?:^|\s)(?:\.atris|atris\/runs)\/|(?:^|\s)\/(?:Users|home|workspace|tmp)\/\S+)/im;
|
|
9
|
+
const COACH_PRESSURE_RE = /\b(?:asap|urgent|time-sensitive|immediately|friendly reminder|just checking in|you still haven'?t|don'?t forget|why haven'?t you|you need to|productive day|great work|nice work|good job|keep the streak)\b/i;
|
|
10
|
+
|
|
11
|
+
const SLOP_RULES = [
|
|
12
|
+
{
|
|
13
|
+
id: 'copy-slop-corporate-filler',
|
|
14
|
+
re: /\b(?:revolutionary|game[- ]changing|cutting[- ]edge|seamlessly|effortlessly|robust|powerful|comprehensive|leverage|utilize|facilitate|synergy|holistic|pivotal|crucial|unlock|supercharge)\b/i,
|
|
15
|
+
message: 'copy contains corporate filler or hype',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'copy-slop-ai-tell',
|
|
19
|
+
re: /\b(?:it'?s worth noting that|as you can see|in order to|at the end of the day|great question|absolutely)\b/i,
|
|
20
|
+
message: 'copy contains an AI-tell phrase',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'copy-slop-punctuation',
|
|
24
|
+
re: /[\u2014\u2728\u{1F680}\u{1F4A1}\u{1F3AF}]/u,
|
|
25
|
+
message: 'copy contains punctuation or decorative symbols blocked by the anti-slop gate',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const VALID_FORMATS = new Set(['plain', 'html', 'markdown', 'visual', 'source']);
|
|
30
|
+
const VALID_COACH_SURFACES = new Set(['morning', 'evening', 'warm-ping']);
|
|
31
|
+
|
|
32
|
+
function usage() {
|
|
33
|
+
return [
|
|
34
|
+
'Usage:',
|
|
35
|
+
' node scripts/outbound-artifact-gate.js --channel email --format plain --body-file body.txt',
|
|
36
|
+
' node scripts/outbound-artifact-gate.js --channel email --format html --body-file body.html --proof-file render.txt',
|
|
37
|
+
'',
|
|
38
|
+
'Options:',
|
|
39
|
+
' --channel <email|slack|doc|deck|web|other>',
|
|
40
|
+
' --format <plain|html|markdown|visual|source>',
|
|
41
|
+
' --body <text>',
|
|
42
|
+
' --body-file <path>',
|
|
43
|
+
' --proof-file <path> Required for html and visual formats',
|
|
44
|
+
' --coach-surface <morning|evening|warm-ping>',
|
|
45
|
+
' --signal-proof <path> Required for a warm-ping coach surface',
|
|
46
|
+
' --visual Require visual proof even when format is not visual',
|
|
47
|
+
' --allow-slop Skip anti-slop copy checks',
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseArgs(argv) {
|
|
52
|
+
const options = {
|
|
53
|
+
channel: 'other',
|
|
54
|
+
format: 'plain',
|
|
55
|
+
body: '',
|
|
56
|
+
bodyFile: null,
|
|
57
|
+
proofFile: null,
|
|
58
|
+
coachSurface: null,
|
|
59
|
+
signalProof: null,
|
|
60
|
+
visual: false,
|
|
61
|
+
allowSlop: false,
|
|
62
|
+
help: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
66
|
+
const arg = argv[i];
|
|
67
|
+
if (!arg.startsWith('--')) {
|
|
68
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const eq = arg.indexOf('=');
|
|
72
|
+
const key = (eq === -1 ? arg.slice(2) : arg.slice(2, eq)).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
73
|
+
const inlineValue = eq === -1 ? null : arg.slice(eq + 1);
|
|
74
|
+
|
|
75
|
+
if (key === 'help') {
|
|
76
|
+
options.help = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (key === 'visual' || key === 'allowSlop') {
|
|
80
|
+
options[key] = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const value = inlineValue !== null ? inlineValue : argv[i + 1];
|
|
85
|
+
if (value === undefined || value.startsWith('--')) {
|
|
86
|
+
throw new Error(`Missing value for --${arg.slice(2)}`);
|
|
87
|
+
}
|
|
88
|
+
i += inlineValue === null ? 1 : 0;
|
|
89
|
+
|
|
90
|
+
if (!Object.prototype.hasOwnProperty.call(options, key)) {
|
|
91
|
+
throw new Error(`Unknown option: --${arg.slice(2)}`);
|
|
92
|
+
}
|
|
93
|
+
options[key] = value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return options;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readBody(options) {
|
|
100
|
+
if (options.bodyFile) {
|
|
101
|
+
return fs.readFileSync(options.bodyFile, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
return String(options.body || '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function proofExists(proofFile) {
|
|
107
|
+
if (!proofFile) return false;
|
|
108
|
+
try {
|
|
109
|
+
fs.statSync(proofFile);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (_err) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function addError(errors, id, message) {
|
|
117
|
+
errors.push({ id, message });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scanOutboundArtifact(options, body) {
|
|
121
|
+
const errors = [];
|
|
122
|
+
const format = String(options.format || 'plain').toLowerCase();
|
|
123
|
+
const channel = String(options.channel || 'other').toLowerCase();
|
|
124
|
+
const coachSurface = options.coachSurface ? String(options.coachSurface).toLowerCase() : null;
|
|
125
|
+
|
|
126
|
+
if (!VALID_FORMATS.has(format)) {
|
|
127
|
+
addError(errors, 'invalid-format', `format must be one of ${Array.from(VALID_FORMATS).join(', ')}`);
|
|
128
|
+
return errors;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (coachSurface && !VALID_COACH_SURFACES.has(coachSurface)) {
|
|
132
|
+
addError(errors, 'invalid-coach-surface', `coach surface must be one of ${Array.from(VALID_COACH_SURFACES).join(', ')}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sendsSource = format === 'source';
|
|
136
|
+
const needsRenderProof = format === 'html' || format === 'visual' || options.visual;
|
|
137
|
+
|
|
138
|
+
if (format === 'plain' && (HTML_TAG_RE.test(body) || ENCODED_HTML_RE.test(body))) {
|
|
139
|
+
addError(errors, 'raw-html-in-plain-body', 'plain body contains HTML source; send rendered HTML or rewrite as plain text');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!sendsSource && RENDERED_SOURCE_FENCE_RE.test(body)) {
|
|
143
|
+
addError(errors, 'rendered-source-fence', 'body contains source that should be rendered before sending');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (channel === 'email' && format === 'markdown' && RENDERED_SOURCE_FENCE_RE.test(body)) {
|
|
147
|
+
addError(errors, 'markdown-email-source', 'email body contains fenced rendered source; attach source intentionally or render it first');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (needsRenderProof && !proofExists(options.proofFile)) {
|
|
151
|
+
addError(errors, 'render-proof-missing', 'HTML or visual sends need --proof-file with preview, screenshot, PDF, or rendered-email receipt');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (coachSurface) {
|
|
155
|
+
if (COACH_INTERNAL_RE.test(body)) {
|
|
156
|
+
addError(errors, 'coach-internal-language', 'coach copy contains an internal id, flag, or path');
|
|
157
|
+
}
|
|
158
|
+
if (COACH_PRESSURE_RE.test(body)) {
|
|
159
|
+
addError(errors, 'coach-pressure-language', 'coach copy contains urgency, guilt, nagging, or generic productivity praise');
|
|
160
|
+
}
|
|
161
|
+
if (coachSurface === 'warm-ping' && !proofExists(options.signalProof)) {
|
|
162
|
+
addError(errors, 'coach-signal-proof-missing', 'warm pings need --signal-proof for the fresh human event');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!options.allowSlop) {
|
|
167
|
+
for (const rule of SLOP_RULES) {
|
|
168
|
+
if (rule.re.test(body)) {
|
|
169
|
+
addError(errors, rule.id, rule.message);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return errors;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function main() {
|
|
178
|
+
let options;
|
|
179
|
+
try {
|
|
180
|
+
options = parseArgs(process.argv.slice(2));
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error(err.message);
|
|
183
|
+
console.error(usage());
|
|
184
|
+
process.exitCode = 2;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (options.help) {
|
|
189
|
+
console.log(usage());
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let body;
|
|
194
|
+
try {
|
|
195
|
+
body = readBody(options);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(`failed to read body: ${err.message}`);
|
|
198
|
+
process.exitCode = 2;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const errors = scanOutboundArtifact(options, body);
|
|
203
|
+
if (errors.length) {
|
|
204
|
+
console.error('outbound artifact gate failed');
|
|
205
|
+
for (const error of errors) {
|
|
206
|
+
console.error(`- ${error.id}: ${error.message}`);
|
|
207
|
+
}
|
|
208
|
+
process.exitCode = 1;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log('outbound artifact gate passed');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (require.main === module) {
|
|
216
|
+
main();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
HTML_TAG_RE,
|
|
221
|
+
RENDERED_SOURCE_FENCE_RE,
|
|
222
|
+
SLOP_RULES,
|
|
223
|
+
COACH_INTERNAL_RE,
|
|
224
|
+
COACH_PRESSURE_RE,
|
|
225
|
+
parseArgs,
|
|
226
|
+
scanOutboundArtifact,
|
|
227
|
+
};
|