atris 3.45.1 → 3.46.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.
- package/AGENTS.md +19 -3
- package/FOR_AGENTS.md +5 -0
- package/README.md +2 -2
- package/atris/AGENTS.md +4 -3
- package/atris/CLAUDE.md +1 -1
- package/atris/atris.md +21 -1
- package/atris/skills/atris/SKILL.md +7 -3
- package/atris/skills/loop/SKILL.md +6 -3
- package/atris.md +18 -1
- package/bin/atris.js +5 -0
- package/commands/autoland.js +25 -3
- package/commands/brain.js +14 -3
- package/commands/ci.js +44 -0
- package/commands/codex-goal.js +21 -119
- package/commands/init.js +33 -10
- package/commands/mission.js +116 -53
- package/commands/pack.js +90 -25
- package/commands/sync.js +34 -6
- package/commands/task.js +239 -31
- package/commands/voice.js +12 -2
- package/lib/ci-runner.js +396 -0
- package/lib/engine-validate.js +141 -1
- package/lib/known-commands.js +1 -1
- package/lib/task-db.js +22 -3
- package/lib/task-explanation.js +229 -0
- package/lib/todo-fallback.js +6 -0
- package/lib/voice-card.js +258 -0
- package/package.json +1 -1
- package/templates/research-canonical/atris.md +6 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One plain-language explanation for every task, on every surface.
|
|
4
|
+
//
|
|
5
|
+
// Three questions a person can answer without reading metadata: what changes,
|
|
6
|
+
// why it matters, what done looks like. Callers may write these fields
|
|
7
|
+
// explicitly; everything else is derived from what the task already carries
|
|
8
|
+
// (title, goal, verifier, parent). Nothing here invents a claim: when a task
|
|
9
|
+
// never recorded a reason, the surface says so instead of guessing one.
|
|
10
|
+
//
|
|
11
|
+
// This is the first layer only. Title, metadata, requirements, events, proof,
|
|
12
|
+
// verify commands, status, and approval gating are untouched and stay
|
|
13
|
+
// inspectable underneath.
|
|
14
|
+
|
|
15
|
+
const EXPLANATION_FIELDS = ['what_changes', 'why_it_matters', 'done_looks_like'];
|
|
16
|
+
|
|
17
|
+
const EXPLANATION_LABELS = {
|
|
18
|
+
what_changes: 'What changes',
|
|
19
|
+
why_it_matters: 'Why it matters',
|
|
20
|
+
done_looks_like: 'Done looks like',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const NO_REASON_RECORDED = 'No reason recorded yet.';
|
|
24
|
+
const NO_TITLE_RECORDED = 'Not written down yet.';
|
|
25
|
+
|
|
26
|
+
const PLAIN_REPLACEMENTS = [
|
|
27
|
+
[/\bidempotent\b/gi, 'safe to repeat'],
|
|
28
|
+
[/\bdeterministic\b/gi, 'predictable'],
|
|
29
|
+
[/\bsubstrate\b/gi, 'base'],
|
|
30
|
+
[/\borchestration\b/gi, 'coordination'],
|
|
31
|
+
[/\binvariant\b/gi, 'rule'],
|
|
32
|
+
[/\bcanonical\b/gi, 'main'],
|
|
33
|
+
[/\bmateriali[sz]e\b/gi, 'create'],
|
|
34
|
+
[/\bheuristic\b/gi, 'rule of thumb'],
|
|
35
|
+
[/\bprojection\b/gi, 'shared view'],
|
|
36
|
+
[/\bschema\b/gi, 'data format'],
|
|
37
|
+
[/\b(?:CLI|API)\b/g, match => match === 'CLI' ? 'command tool' : 'connection'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function cleanText(value) {
|
|
41
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function endSentence(value) {
|
|
45
|
+
const text = cleanText(value);
|
|
46
|
+
if (!text) return '';
|
|
47
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The original value always remains in title/metadata/events. This copy is
|
|
51
|
+
// only the simple face, so identifiers and common internal terms are expanded
|
|
52
|
+
// here without weakening or rewriting the durable task record.
|
|
53
|
+
function plainText(value) {
|
|
54
|
+
let text = cleanText(value)
|
|
55
|
+
.replace(/\s*[—–]\s*/g, ', ')
|
|
56
|
+
.replace(/--([a-z][a-z-]*)/g, (_match, flag) => flag.replace(/-/g, ' '))
|
|
57
|
+
.replace(/\b[a-z0-9]+(?:_[a-z0-9]+)+\b/gi, match => match.replace(/_/g, ' '))
|
|
58
|
+
.replace(/\b[a-z]+(?:[A-Z][A-Za-z0-9]*)+\b/g, match => match.replace(/([a-z0-9])([A-Z])/g, '$1 $2'))
|
|
59
|
+
.replace(/\b[A-Z]{2,5}-\d+\b/g, '')
|
|
60
|
+
.replace(/\b[0-9A-HJKMNP-TV-Z]{20,}\b/g, '')
|
|
61
|
+
.replace(/(?:^|\s)(?:[\w.-]+\/)+[\w.-]+(?=\s|$|[.,;:!?])/g, ' the named file');
|
|
62
|
+
for (const [pattern, replacement] of PLAIN_REPLACEMENTS) text = text.replace(pattern, replacement);
|
|
63
|
+
return cleanText(text).replace(/\s+([,.;:!?])/g, '$1');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Explicit fields arrive either nested (metadata.explanation, written at
|
|
67
|
+
// creation) or flat (metadata.what_changes, written by a caller flag). A
|
|
68
|
+
// stored field only counts as explicit when its recorded source says so, so a
|
|
69
|
+
// derived default persisted at creation never masquerades as an author's words
|
|
70
|
+
// and never goes stale after a retitle.
|
|
71
|
+
function explicitExplanationFields(metadata) {
|
|
72
|
+
const out = {};
|
|
73
|
+
if (!metadata || typeof metadata !== 'object') return out;
|
|
74
|
+
const nested = metadata.explanation && typeof metadata.explanation === 'object' ? metadata.explanation : {};
|
|
75
|
+
const nestedSources = nested.sources && typeof nested.sources === 'object' ? nested.sources : null;
|
|
76
|
+
for (const field of EXPLANATION_FIELDS) {
|
|
77
|
+
const authored = !nestedSources || nestedSources[field] === 'explicit';
|
|
78
|
+
const value = (authored ? cleanText(nested[field]) : '') || cleanText(metadata[field]);
|
|
79
|
+
if (value) out[field] = value;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function explanationGoalText(task) {
|
|
85
|
+
const metadata = task && task.metadata || {};
|
|
86
|
+
return cleanText(metadata.task_goal || metadata.goal_objective || (task && task.objective) || metadata.objective);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function derivedWhatChanges(task) {
|
|
90
|
+
const title = plainText(task && task.title);
|
|
91
|
+
return title ? endSentence(title) : NO_TITLE_RECORDED;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function derivedWhyItMatters(task) {
|
|
95
|
+
const goal = plainText(explanationGoalText(task));
|
|
96
|
+
if (goal) return endSentence(goal);
|
|
97
|
+
const parent = plainText(task && task.lineage && task.lineage.parent_title);
|
|
98
|
+
if (parent) return endSentence(`This helps finish the larger work: ${parent}`);
|
|
99
|
+
return 'The task does not say why this matters yet.';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function derivedDoneLooksLike(task) {
|
|
103
|
+
const metadata = task && task.metadata || {};
|
|
104
|
+
const review = task && task.review || {};
|
|
105
|
+
const result = plainText(
|
|
106
|
+
task && task.result
|
|
107
|
+
|| metadata.result
|
|
108
|
+
|| review.result && review.result.changed
|
|
109
|
+
|| review.landing && review.landing.happened
|
|
110
|
+
);
|
|
111
|
+
if (result) return endSentence(result);
|
|
112
|
+
const exit = plainText(metadata.exit_condition);
|
|
113
|
+
if (exit) return endSentence(exit);
|
|
114
|
+
const verify = cleanText(metadata.verify);
|
|
115
|
+
if (verify) return 'The required check passes, the proof is attached, and review clears the work.';
|
|
116
|
+
return 'The owner shows proof the work is real and it clears review.';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Task in, plain explanation out. Works on a raw task row, a projection task,
|
|
120
|
+
// or a bare { title, tag, metadata } shape, so every surface reads the same
|
|
121
|
+
// three sentences.
|
|
122
|
+
function taskExplanation(task) {
|
|
123
|
+
const explicit = explicitExplanationFields(task && task.metadata);
|
|
124
|
+
const derived = {
|
|
125
|
+
what_changes: derivedWhatChanges(task),
|
|
126
|
+
why_it_matters: derivedWhyItMatters(task),
|
|
127
|
+
done_looks_like: derivedDoneLooksLike(task),
|
|
128
|
+
};
|
|
129
|
+
const explanation = { sources: {} };
|
|
130
|
+
for (const field of EXPLANATION_FIELDS) {
|
|
131
|
+
explanation[field] = endSentence(plainText(explicit[field])) || derived[field];
|
|
132
|
+
explanation.sources[field] = explicit[field] ? 'explicit' : 'derived';
|
|
133
|
+
}
|
|
134
|
+
explanation.authored = EXPLANATION_FIELDS.every(field => explanation.sources[field] === 'explicit');
|
|
135
|
+
explanation.reason_recorded = Boolean(explicit.why_it_matters || explanationGoalText(task));
|
|
136
|
+
return explanation;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Pull caller-supplied explanation fields off an options bag or an API body.
|
|
140
|
+
// Only the exact field names count, so ordinary metadata keys can never be
|
|
141
|
+
// mistaken for an author's sentence.
|
|
142
|
+
function explanationFieldsFromInput(input) {
|
|
143
|
+
const out = {};
|
|
144
|
+
if (!input || typeof input !== 'object') return out;
|
|
145
|
+
const nested = input.explanation && typeof input.explanation === 'object' ? input.explanation : {};
|
|
146
|
+
for (const field of EXPLANATION_FIELDS) {
|
|
147
|
+
const camel = field.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
148
|
+
for (const key of [field, camel]) {
|
|
149
|
+
const value = cleanText(nested[key] !== undefined ? nested[key] : input[key]);
|
|
150
|
+
if (value) { out[field] = value; break; }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Dense rows (list, day, desk, TODO) already print the title on the line
|
|
157
|
+
// above. Pass that title and a derived what_changes, which is the title, drops
|
|
158
|
+
// out instead of echoing. An authored what_changes always survives, because it
|
|
159
|
+
// says something the title does not.
|
|
160
|
+
function explanationFieldList(explanation, { title = null } = {}) {
|
|
161
|
+
if (title == null) return EXPLANATION_FIELDS;
|
|
162
|
+
const echo = endSentence(title);
|
|
163
|
+
return EXPLANATION_FIELDS.filter(field => !(field === 'what_changes' && explanation.what_changes === echo));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function explanationLines(explanation, { indent = '', title = null } = {}) {
|
|
167
|
+
return explanationFieldList(explanation, { title })
|
|
168
|
+
.map(field => `${indent}${EXPLANATION_LABELS[field]}: ${explanation[field]}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function explanationMarkdownLines(explanation, { indent = ' ', title = null } = {}) {
|
|
172
|
+
return explanationFieldList(explanation, { title })
|
|
173
|
+
.map(field => `${indent}**${EXPLANATION_LABELS[field]}:** ${explanation[field]}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The approval half of the first layer: approve the work, or ask for a change.
|
|
177
|
+
// The caller passes the answer from the existing certification gate; this
|
|
178
|
+
// helper only labels it. It can never widen what acceptance allows.
|
|
179
|
+
function taskApprovalControls({
|
|
180
|
+
question = null,
|
|
181
|
+
approveLabel = 'Approve this work',
|
|
182
|
+
acceptEnabled = false,
|
|
183
|
+
acceptCommand = null,
|
|
184
|
+
requestChangeEnabled = false,
|
|
185
|
+
requestChangeCommand = null,
|
|
186
|
+
blockedReason = null,
|
|
187
|
+
waitingOn = null,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const approveEnabled = Boolean(acceptEnabled && acceptCommand);
|
|
190
|
+
return {
|
|
191
|
+
question: question || (approveEnabled || requestChangeEnabled
|
|
192
|
+
? 'Approve this work, or ask for a change?'
|
|
193
|
+
: 'Nothing to approve yet.'),
|
|
194
|
+
approve: {
|
|
195
|
+
label: approveLabel,
|
|
196
|
+
enabled: approveEnabled,
|
|
197
|
+
command: approveEnabled ? acceptCommand : null,
|
|
198
|
+
human_only: true,
|
|
199
|
+
blocked_reason: approveEnabled ? null : (blockedReason || null),
|
|
200
|
+
},
|
|
201
|
+
request_change: {
|
|
202
|
+
label: 'Ask for a change',
|
|
203
|
+
enabled: Boolean(requestChangeEnabled && requestChangeCommand),
|
|
204
|
+
command: requestChangeEnabled ? requestChangeCommand : null,
|
|
205
|
+
},
|
|
206
|
+
waiting_on: waitingOn || null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function approvalLines(approval, { indent = '' } = {}) {
|
|
211
|
+
const lines = [`${indent}${approval.question}`];
|
|
212
|
+
if (approval.approve.enabled) lines.push(`${indent}Approve: ${approval.approve.command}`);
|
|
213
|
+
else if (approval.approve.blocked_reason) lines.push(`${indent}Cannot approve yet: ${approval.approve.blocked_reason}`);
|
|
214
|
+
if (approval.request_change.enabled) lines.push(`${indent}Ask for a change: ${approval.request_change.command}`);
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
EXPLANATION_FIELDS,
|
|
220
|
+
EXPLANATION_LABELS,
|
|
221
|
+
NO_REASON_RECORDED,
|
|
222
|
+
plainText,
|
|
223
|
+
taskExplanation,
|
|
224
|
+
explanationFieldsFromInput,
|
|
225
|
+
explanationLines,
|
|
226
|
+
explanationMarkdownLines,
|
|
227
|
+
taskApprovalControls,
|
|
228
|
+
approvalLines,
|
|
229
|
+
};
|
package/lib/todo-fallback.js
CHANGED
|
@@ -125,6 +125,12 @@ function parseSection(content, sectionName) {
|
|
|
125
125
|
|
|
126
126
|
const verifyMatch = line.match(/\*\*Verify:\*\*\s*(.+)$/) || line.match(/Verify:\s*(.+)$/);
|
|
127
127
|
if (verifyMatch) { current.verify = verifyMatch[1].trim(); continue; }
|
|
128
|
+
|
|
129
|
+
// Generated task boards lead with the plain explanation, then keep the
|
|
130
|
+
// exact original title here. The TODO-only fallback must restore that
|
|
131
|
+
// original title so presentation never weakens task identity or logic.
|
|
132
|
+
const technicalDetailsMatch = line.match(/\*\*Technical details:\*\*\s*(.+)$/i);
|
|
133
|
+
if (technicalDetailsMatch) { current.title = technicalDetailsMatch[1].trim(); continue; }
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
if (current) tasks.push(current);
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
7
|
+
const VOICE_CARD_START = '<!-- ATRIS_VOICE_CARD:START -->';
|
|
8
|
+
const VOICE_CARD_END = '<!-- ATRIS_VOICE_CARD:END -->';
|
|
9
|
+
const BRAIN_START = '<!-- ATRIS_BRAIN_COMPILE:START -->';
|
|
10
|
+
const BRAIN_END = '<!-- ATRIS_BRAIN_COMPILE:END -->';
|
|
11
|
+
const CLAUDE_VOICE_HOOK_COMMAND = 'command -v atris >/dev/null 2>&1 && atris voice card --hook || true';
|
|
12
|
+
|
|
13
|
+
function extractVoiceDoctrine(content) {
|
|
14
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
15
|
+
const start = lines.findIndex((line) => /^## voice\s*$/i.test(line));
|
|
16
|
+
if (start === -1) return '';
|
|
17
|
+
let end = lines.length;
|
|
18
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
19
|
+
if (/^##\s+/.test(lines[index])) {
|
|
20
|
+
end = index;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return lines.slice(start, end).join('\n').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readVoiceDoctrine(root = process.cwd()) {
|
|
28
|
+
const candidates = [
|
|
29
|
+
path.join(root, 'atris.md'),
|
|
30
|
+
path.join(root, 'atris', 'atris.md'),
|
|
31
|
+
];
|
|
32
|
+
for (const file of candidates) {
|
|
33
|
+
if (!fs.existsSync(file)) continue;
|
|
34
|
+
const doctrine = extractVoiceDoctrine(fs.readFileSync(file, 'utf8'));
|
|
35
|
+
if (doctrine) return doctrine;
|
|
36
|
+
}
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function principleSentence(doctrine, label, fallback) {
|
|
41
|
+
const line = String(doctrine || '')
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.find((candidate) => candidate.startsWith('- **') && label.test(candidate));
|
|
44
|
+
if (!line) return fallback;
|
|
45
|
+
const body = line.replace(/^- \*\*[^*]+\*\*\s*/, '').trim();
|
|
46
|
+
const sentence = body.match(/^.*?[.!?](?:\s|$)/);
|
|
47
|
+
return (sentence ? sentence[0] : body)
|
|
48
|
+
.replace(/`/g, '')
|
|
49
|
+
.replace(/\s+/g, ' ')
|
|
50
|
+
.trim() || fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function naturalLead(source) {
|
|
54
|
+
if (source === 'Answer first, support after.') {
|
|
55
|
+
return 'Start with the answer, then give the reader only what helps them act.';
|
|
56
|
+
}
|
|
57
|
+
return source;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function naturalSpecific(source) {
|
|
61
|
+
if (source === 'Name the exact thing.') {
|
|
62
|
+
return 'Name the exact thing in plain words, like you are talking to a person.';
|
|
63
|
+
}
|
|
64
|
+
return source;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function composeVoiceCard(doctrine = readVoiceDoctrine(PACKAGE_ROOT)) {
|
|
68
|
+
const lead = naturalLead(principleSentence(
|
|
69
|
+
doctrine,
|
|
70
|
+
/Lead with the move/i,
|
|
71
|
+
'Start with the answer, then give the reader only what helps them act.',
|
|
72
|
+
));
|
|
73
|
+
const specific = naturalSpecific(principleSentence(
|
|
74
|
+
doctrine,
|
|
75
|
+
/Specific over buzzy/i,
|
|
76
|
+
'Name the exact thing in plain words, like you are talking to a person.',
|
|
77
|
+
));
|
|
78
|
+
|
|
79
|
+
return `${lead} ${specific}
|
|
80
|
+
|
|
81
|
+
Keep each paragraph to one or two sentences and leave a blank line between thoughts. Use a comma or period instead of an em dash.
|
|
82
|
+
|
|
83
|
+
Status example:
|
|
84
|
+
The reply check is built. I am running the final checks now, so the result is not ready yet.
|
|
85
|
+
|
|
86
|
+
Landing example:
|
|
87
|
+
Replies now get a plain-language check before they reach you. The checks passed, and the change is ready.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const VOICE_CARD = composeVoiceCard();
|
|
91
|
+
|
|
92
|
+
function voiceCardForRoot(root = process.cwd()) {
|
|
93
|
+
const doctrine = readVoiceDoctrine(root) || readVoiceDoctrine(PACKAGE_ROOT);
|
|
94
|
+
return composeVoiceCard(doctrine);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildVoiceCardHookJson(card = VOICE_CARD) {
|
|
98
|
+
return {
|
|
99
|
+
suppressOutput: true,
|
|
100
|
+
hookSpecificOutput: {
|
|
101
|
+
hookEventName: 'UserPromptSubmit',
|
|
102
|
+
additionalContext: card,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderVoiceCardBlock(card = VOICE_CARD) {
|
|
108
|
+
return `${VOICE_CARD_START}
|
|
109
|
+
## Voice card
|
|
110
|
+
|
|
111
|
+
${card}
|
|
112
|
+
${VOICE_CARD_END}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function replaceOrAppendVoiceBlock(source, card) {
|
|
116
|
+
const block = renderVoiceCardBlock(card);
|
|
117
|
+
const start = source.indexOf(VOICE_CARD_START);
|
|
118
|
+
const end = source.indexOf(VOICE_CARD_END, start + VOICE_CARD_START.length);
|
|
119
|
+
if (start !== -1 && end !== -1) {
|
|
120
|
+
return `${source.slice(0, start)}${block}${source.slice(end + VOICE_CARD_END.length)}`;
|
|
121
|
+
}
|
|
122
|
+
const separator = source.trim() ? '\n\n' : '';
|
|
123
|
+
return `${source.trimEnd()}${separator}${block}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ensureAlwaysApplyFrontmatter(source) {
|
|
127
|
+
if (!source.startsWith('---\n') && !source.startsWith('---\r\n')) {
|
|
128
|
+
return `---\nalwaysApply: true\n---\n\n${source.replace(/^\s+/, '')}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const normalized = source.replace(/\r\n/g, '\n');
|
|
132
|
+
const closing = normalized.indexOf('\n---', 4);
|
|
133
|
+
if (closing === -1) {
|
|
134
|
+
return `---\nalwaysApply: true\n---\n\n${source}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const frontmatter = normalized.slice(4, closing).split('\n');
|
|
138
|
+
const alwaysApply = frontmatter.findIndex((line) => /^alwaysApply\s*:/.test(line));
|
|
139
|
+
if (alwaysApply === -1) frontmatter.push('alwaysApply: true');
|
|
140
|
+
else frontmatter[alwaysApply] = 'alwaysApply: true';
|
|
141
|
+
return `---\n${frontmatter.join('\n')}\n---${normalized.slice(closing + 4)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function upsertCursorVoiceCard(filePath, card = VOICE_CARD, options = {}) {
|
|
145
|
+
const source = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
146
|
+
const withFrontmatter = ensureAlwaysApplyFrontmatter(source);
|
|
147
|
+
const content = replaceOrAppendVoiceBlock(withFrontmatter, card);
|
|
148
|
+
const changed = content !== source;
|
|
149
|
+
if (changed && !options.dryRun) {
|
|
150
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
151
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
152
|
+
}
|
|
153
|
+
return { action: source ? (changed ? 'updated' : 'unchanged') : 'created', content };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function upsertAgentVoiceCard(filePath, card = VOICE_CARD, options = {}) {
|
|
157
|
+
const source = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
158
|
+
const brainStart = source.indexOf(BRAIN_START);
|
|
159
|
+
const brainEnd = source.indexOf(BRAIN_END, brainStart + BRAIN_START.length);
|
|
160
|
+
let content;
|
|
161
|
+
|
|
162
|
+
if (brainStart !== -1 && brainEnd !== -1) {
|
|
163
|
+
const block = source.slice(brainStart, brainEnd + BRAIN_END.length);
|
|
164
|
+
const updatedBlock = replaceOrAppendVoiceBlock(
|
|
165
|
+
block.slice(0, -BRAIN_END.length).trimEnd(),
|
|
166
|
+
card,
|
|
167
|
+
).trimEnd() + `\n${BRAIN_END}`;
|
|
168
|
+
content = `${source.slice(0, brainStart)}${updatedBlock}${source.slice(brainEnd + BRAIN_END.length)}`;
|
|
169
|
+
} else {
|
|
170
|
+
const prefix = source.trimEnd() || '# AGENTS';
|
|
171
|
+
content = `${prefix}\n\n${BRAIN_START}\n## Atris Brain Compile\n\n${renderVoiceCardBlock(card)}\n${BRAIN_END}\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const changed = content !== source;
|
|
175
|
+
if (changed && !options.dryRun) {
|
|
176
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
177
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
return { action: source ? (changed ? 'updated' : 'unchanged') : 'created', content };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function voiceHookEntry() {
|
|
183
|
+
return {
|
|
184
|
+
hooks: [
|
|
185
|
+
{
|
|
186
|
+
type: 'command',
|
|
187
|
+
command: CLAUDE_VOICE_HOOK_COMMAND,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPlainObject(value) {
|
|
194
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function invalidSettings(filePath, warn, reason) {
|
|
198
|
+
const warning = `could not add the atris voice hook to ${path.relative(process.cwd(), filePath) || filePath} because ${reason}; the file was left unchanged.`;
|
|
199
|
+
warn(warning);
|
|
200
|
+
return { action: 'skipped', warning };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function upsertClaudeVoiceHook(filePath, options = {}) {
|
|
204
|
+
const exists = fs.existsSync(filePath);
|
|
205
|
+
const source = exists ? fs.readFileSync(filePath, 'utf8') : null;
|
|
206
|
+
const warn = options.warn || console.warn;
|
|
207
|
+
let settings;
|
|
208
|
+
|
|
209
|
+
if (exists) {
|
|
210
|
+
try {
|
|
211
|
+
settings = JSON.parse(source);
|
|
212
|
+
} catch {
|
|
213
|
+
return invalidSettings(filePath, warn, 'it is not valid json');
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
settings = JSON.parse(JSON.stringify(options.initialSettings || {}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!isPlainObject(settings)) {
|
|
220
|
+
return invalidSettings(filePath, warn, 'its top level is not an object');
|
|
221
|
+
}
|
|
222
|
+
if (settings.hooks === undefined) settings.hooks = {};
|
|
223
|
+
if (!isPlainObject(settings.hooks)) {
|
|
224
|
+
return invalidSettings(filePath, warn, 'its hooks value is not an object');
|
|
225
|
+
}
|
|
226
|
+
if (settings.hooks.UserPromptSubmit === undefined) settings.hooks.UserPromptSubmit = [];
|
|
227
|
+
if (!Array.isArray(settings.hooks.UserPromptSubmit)) {
|
|
228
|
+
return invalidSettings(filePath, warn, 'its UserPromptSubmit hooks are not a list');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const present = settings.hooks.UserPromptSubmit.some((group) => (
|
|
232
|
+
group && Array.isArray(group.hooks) && group.hooks.some((hook) => (
|
|
233
|
+
hook && typeof hook.command === 'string' && hook.command.includes('atris voice card --hook')
|
|
234
|
+
))
|
|
235
|
+
));
|
|
236
|
+
if (present) return { action: 'unchanged', content: source };
|
|
237
|
+
|
|
238
|
+
settings.hooks.UserPromptSubmit.push(voiceHookEntry());
|
|
239
|
+
const content = `${JSON.stringify(settings, null, 2)}\n`;
|
|
240
|
+
if (!options.dryRun) {
|
|
241
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
242
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
243
|
+
}
|
|
244
|
+
return { action: exists ? 'updated' : 'created', content };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
CLAUDE_VOICE_HOOK_COMMAND,
|
|
249
|
+
VOICE_CARD,
|
|
250
|
+
buildVoiceCardHookJson,
|
|
251
|
+
composeVoiceCard,
|
|
252
|
+
readVoiceDoctrine,
|
|
253
|
+
renderVoiceCardBlock,
|
|
254
|
+
upsertAgentVoiceCard,
|
|
255
|
+
upsertClaudeVoiceHook,
|
|
256
|
+
upsertCursorVoiceCard,
|
|
257
|
+
voiceCardForRoot,
|
|
258
|
+
};
|
package/package.json
CHANGED
|
@@ -25,6 +25,12 @@ Use `atris task` when available. It stores durable local SQLite task state,
|
|
|
25
25
|
append-only task events, and refreshes `.atris/state/tasks.projection.json` for
|
|
26
26
|
desktop/web/agent views.
|
|
27
27
|
|
|
28
|
+
Every task view leads with what changes, why it matters, and what done looks
|
|
29
|
+
like in plain words. Exact titles, requirements, sources, commands, events,
|
|
30
|
+
proof, and approval rules remain underneath unchanged. Planned work uses the
|
|
31
|
+
existing approve-or-change path; completed work still needs the existing proof
|
|
32
|
+
and human acceptance gates.
|
|
33
|
+
|
|
28
34
|
`atris/TODO.md` is the readable fallback/projection. It can be rebuilt with
|
|
29
35
|
`atris task render --out atris/TODO.md`; do not rely on manual TODO.md edits for
|
|
30
36
|
ownership. In cloud business workspaces, Supabase `tasks` is the source of truth
|