@adrata/adrata-mcp 1.0.8 → 1.0.30
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/access/oauth.js +27 -0
- package/access/tiers.js +14 -0
- package/api-bridge.js +26 -0
- package/governance/governed-args.js +7 -2
- package/http/edge-block.js +16 -7
- package/http/rate-limit.js +69 -6
- package/package.json +3 -3
- package/security.js +167 -14
- package/server.js +35 -23
- package/server.json +2 -2
- package/skills/qa-the-card/SKILL.md +40 -4
- package/tool-annotations.js +23 -0
- package/tools/provisioning/onboarding-tools.js +730 -0
- package/tools/source-control/connection-tools.js +117 -1
- package/tools/work-board-tools.js +441 -25
- package/tools/work-hub/audit.js +74 -8
- package/tools/work-hub/criteria-quality.js +212 -0
- package/toolsets/communications.js +42 -64
- package/toolsets/spaces.js +10 -1
package/tools/work-hub/audit.js
CHANGED
|
@@ -13,9 +13,9 @@ const TERMINAL_STAGES = new Set(['production', 'deep backlog']);
|
|
|
13
13
|
// has actually taken a pass and therefore require both an end-to-end owner and
|
|
14
14
|
// a current handler.
|
|
15
15
|
const ACTIVE_STAGES = new Set(['aligning', 'in progress', 'staging qa1', 'staging qa2']);
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
const EXECUTABLE_STAGES = new Set(['up next', ...ACTIVE_STAGES, 'production']);
|
|
16
|
+
// Acceptance and release still require executable facts, but do not require
|
|
17
|
+
// an active build/QA handler. Stage placement alone is not delivery evidence.
|
|
18
|
+
const EXECUTABLE_STAGES = new Set(['up next', ...ACTIVE_STAGES, 'ready to ship', 'production']);
|
|
19
19
|
|
|
20
20
|
function normalized(value) {
|
|
21
21
|
return String(value ?? '')
|
|
@@ -51,9 +51,14 @@ export function deliveryContradictions(evidence) {
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* @param {Array<object>} boards Full board payloads from GET /work-boards/{id}.
|
|
54
|
-
* @param {{now?: Date|string|number}} options
|
|
54
|
+
* @param {{now?: Date|string|number, expectedBoards?: number}} options
|
|
55
|
+
* `expectedBoards` is how many boards the caller LISTED, before any of their
|
|
56
|
+
* detail reads failed. Pass it whenever the two numbers can differ; it is the
|
|
57
|
+
* only way this function can tell "every board is clean" from "most boards
|
|
58
|
+
* never arrived". It defaults to the number handed in, so a caller that reads
|
|
59
|
+
* boards by another route is not forced to invent one.
|
|
55
60
|
*/
|
|
56
|
-
export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
|
|
61
|
+
export function auditWorkHubBoards(boards, { now = new Date(), expectedBoards } = {}) {
|
|
57
62
|
const nowMs = new Date(now).getTime();
|
|
58
63
|
const findings = [];
|
|
59
64
|
const perBoard = [];
|
|
@@ -61,13 +66,61 @@ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
|
|
|
61
66
|
let openCards = 0;
|
|
62
67
|
let activeCards = 0;
|
|
63
68
|
|
|
69
|
+
// A scan with nothing in it is the one result this audit must never report as
|
|
70
|
+
// health. `trustworthy` is derived from an empty finding list, so a workspace
|
|
71
|
+
// that returned no boards — or whose board reads all failed — would otherwise
|
|
72
|
+
// read exactly like a workspace whose every card is in order, and read that
|
|
73
|
+
// way in the confident words below. The repo has this failure written down
|
|
74
|
+
// (`scripts/check-context-stores.sh` refuses to "pass vacuously"); this is the
|
|
75
|
+
// same floor, in the same shape.
|
|
76
|
+
const expected = Number.isFinite(expectedBoards) ? expectedBoards : boards.length;
|
|
77
|
+
if (boards.length === 0) {
|
|
78
|
+
findings.push({
|
|
79
|
+
boardId: null,
|
|
80
|
+
boardName: null,
|
|
81
|
+
code: expected > 0 ? 'boards_unreadable' : 'no_boards_read',
|
|
82
|
+
itemId: null,
|
|
83
|
+
title: null,
|
|
84
|
+
stage: null,
|
|
85
|
+
detail:
|
|
86
|
+
expected > 0
|
|
87
|
+
? `All ${expected} listed board(s) failed to read. This audit covered nothing.`
|
|
88
|
+
: 'No board was returned, so nothing was audited. An empty scan is not a clean scan.',
|
|
89
|
+
});
|
|
90
|
+
} else if (expected > boards.length) {
|
|
91
|
+
findings.push({
|
|
92
|
+
boardId: null,
|
|
93
|
+
boardName: null,
|
|
94
|
+
code: 'boards_unreadable',
|
|
95
|
+
itemId: null,
|
|
96
|
+
title: null,
|
|
97
|
+
stage: null,
|
|
98
|
+
detail: `${expected - boards.length} of ${expected} listed board(s) failed to read and were not audited. The findings below cover ${boards.length} board(s) only.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
for (const board of boards) {
|
|
65
103
|
const columns = new Map((board.columns ?? []).map((column) => [column.id, column]));
|
|
66
104
|
const boardFindings = [];
|
|
67
|
-
|
|
105
|
+
// An absent `items` is NOT an empty board. Collapsing the two is how a
|
|
106
|
+
// partial payload comes to certify a board nobody actually read.
|
|
107
|
+
const itemsUnreadable = !Array.isArray(board.items);
|
|
108
|
+
const items = itemsUnreadable ? [] : board.items;
|
|
68
109
|
totalCards += items.length;
|
|
69
110
|
|
|
70
|
-
if (
|
|
111
|
+
if (itemsUnreadable) {
|
|
112
|
+
boardFindings.push({
|
|
113
|
+
code: 'board_items_unreadable',
|
|
114
|
+
itemId: null,
|
|
115
|
+
title: null,
|
|
116
|
+
detail: 'This board payload carried no card list, so none of its cards were audited.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
board.truncated === true ||
|
|
122
|
+
(!itemsUnreadable && Number.isInteger(board.itemCount) && board.itemCount > items.length)
|
|
123
|
+
) {
|
|
71
124
|
boardFindings.push({
|
|
72
125
|
code: 'board_truncated',
|
|
73
126
|
itemId: null,
|
|
@@ -89,13 +142,19 @@ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
|
|
|
89
142
|
const add = (code, detail) =>
|
|
90
143
|
boardFindings.push({ code, itemId: item.id, title: item.title, stage, detail });
|
|
91
144
|
|
|
145
|
+
if (!column || !normalized(column.name)) {
|
|
146
|
+
add(
|
|
147
|
+
'card_stage_unreadable',
|
|
148
|
+
'The card has no resolvable stage, so its readiness requirements could not be audited.'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
92
151
|
if (mustBeExecutable && !String(item.body ?? '').trim()) {
|
|
93
152
|
add('missing_body', 'The card crossed the cut line without working context.');
|
|
94
153
|
}
|
|
95
154
|
if (mustBeExecutable) {
|
|
96
155
|
if (item.criteria?.total === 0) {
|
|
97
156
|
add('missing_acceptance_criteria', 'Nobody has stated a checkable definition of done.');
|
|
98
|
-
} else if (item.criteria
|
|
157
|
+
} else if (!Number.isInteger(item.criteria?.total) || item.criteria.total < 0) {
|
|
99
158
|
add('criteria_not_measured', 'This read did not include acceptance-criteria status.');
|
|
100
159
|
}
|
|
101
160
|
if (!item.kind)
|
|
@@ -142,7 +201,13 @@ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
|
|
|
142
201
|
for (const finding of findings) counts[finding.code] = (counts[finding.code] ?? 0) + 1;
|
|
143
202
|
|
|
144
203
|
const priorityOrder = [
|
|
204
|
+
// Coverage first, always. A finding about a card is worth reading only once
|
|
205
|
+
// you know which cards were in the scan at all.
|
|
206
|
+
'no_boards_read',
|
|
207
|
+
'boards_unreadable',
|
|
208
|
+
'board_items_unreadable',
|
|
145
209
|
'board_truncated',
|
|
210
|
+
'card_stage_unreadable',
|
|
146
211
|
'unowned_active_card',
|
|
147
212
|
'unhandled_active_pass',
|
|
148
213
|
'missing_acceptance_criteria',
|
|
@@ -158,6 +223,7 @@ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
|
|
|
158
223
|
trustworthy: findings.length === 0,
|
|
159
224
|
generatedAt: new Date(nowMs).toISOString(),
|
|
160
225
|
boards: boards.length,
|
|
226
|
+
boardsExpected: expected,
|
|
161
227
|
totalCards,
|
|
162
228
|
openCards,
|
|
163
229
|
activeCards,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can a second person falsify this criterion?
|
|
3
|
+
*
|
|
4
|
+
* # Why this exists
|
|
5
|
+
*
|
|
6
|
+
* A Starfield criterion is four fields — where, given, when, then — and the
|
|
7
|
+
* server checks exactly three things about each part: it is not blank, it is
|
|
8
|
+
* under 600 characters, and it is not a pasted transcript
|
|
9
|
+
* (`criteria/edit.rs::validate_part`). Every one of those is a check on the
|
|
10
|
+
* STRING. None of them is a check on whether the sentence says something a
|
|
11
|
+
* reviewer could disagree with.
|
|
12
|
+
*
|
|
13
|
+
* So `thenText: "it works correctly"` is a valid criterion today. It will be
|
|
14
|
+
* ticked, it will count toward `met`, and at the QA gate it will be graded on
|
|
15
|
+
* WHO ticked it and in WHICH dwell — both of which can be perfectly in order
|
|
16
|
+
* while the sentence itself remains unfalsifiable. The independence machinery
|
|
17
|
+
* in `criteria/rule.rs` is strong and this is the gap underneath it: it
|
|
18
|
+
* guarantees that a different pair of hands checked the criterion, not that the
|
|
19
|
+
* criterion could have failed.
|
|
20
|
+
*
|
|
21
|
+
* The rubric this borrows from is the repo's own `task-validation` skill
|
|
22
|
+
* (`.claude/skills/task-validation/references/task-rubrics.md`, metric 6,
|
|
23
|
+
* "Acceptance Criteria Quality"), which names the failure directly:
|
|
24
|
+
*
|
|
25
|
+
* BAD (Score: 50):
|
|
26
|
+
* - "Button looks good" (subjective)
|
|
27
|
+
* - "Works correctly" (vague)
|
|
28
|
+
* - "Performance is acceptable" (unmeasurable)
|
|
29
|
+
*
|
|
30
|
+
* That rubric has never run: it scores YAML task files by hand and is wired to
|
|
31
|
+
* no command, no hook and no CI job. What follows is the checkable third of it,
|
|
32
|
+
* moved to the place where criteria are actually written.
|
|
33
|
+
*
|
|
34
|
+
* # What it deliberately does NOT do
|
|
35
|
+
*
|
|
36
|
+
* It does not score. `audit_work_hub` says in its own description that it "does
|
|
37
|
+
* not award a vanity score", and that judgement holds here: a criterion graded
|
|
38
|
+
* 72/100 tells a reader nothing they can act on, and the only recorded run of
|
|
39
|
+
* the SPOQ rubric in this repo scored nine consecutive tasks 98/100 — a number
|
|
40
|
+
* that had stopped distinguishing between them. Findings are named or absent.
|
|
41
|
+
*
|
|
42
|
+
* It does not block. `describeMissingBody` states the argument already: "a
|
|
43
|
+
* refused capture is how work stops reaching the board at all." These are
|
|
44
|
+
* advisory notes returned beside a dry-run preview and beside a criteria read,
|
|
45
|
+
* where a person or an agent is already deciding what to write.
|
|
46
|
+
*
|
|
47
|
+
* # Why the patterns are anchored
|
|
48
|
+
*
|
|
49
|
+
* Every rule below matches a WHOLE normalised field, or a whole word for
|
|
50
|
+
* placeholders. A substring rule would fire on "the row saves successfully and
|
|
51
|
+
* the toast names the new owner", which is a good criterion containing a weak
|
|
52
|
+
* word. The cost of that false positive is an author learning to ignore the
|
|
53
|
+
* notes, which is worse than the check not existing. When in doubt these rules
|
|
54
|
+
* stay silent.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** Lowercase, collapse whitespace, drop trailing punctuation. */
|
|
58
|
+
function normalise(value) {
|
|
59
|
+
return String(value ?? '')
|
|
60
|
+
.trim()
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.replace(/[.!]+$/, '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The article is optional because the rubric's own worked example is the
|
|
67
|
+
// article-less form — "Button looks good" — and an author writing a weak
|
|
68
|
+
// criterion is exactly the author who drops it.
|
|
69
|
+
const SUBJECT =
|
|
70
|
+
'(?:it|this|(?:the )?(?:app|feature|page|screen|button|toggle|flow|ui|component|form|list|modal|panel|view|result|change|fix|thing))';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A `then` that asserts nothing observable.
|
|
74
|
+
*
|
|
75
|
+
* Anchored whole-string, so "the toggle works" fires and "the toggle works on a
|
|
76
|
+
* cold reload and the label reads Dark" does not.
|
|
77
|
+
*/
|
|
78
|
+
const UNFALSIFIABLE_THEN = [
|
|
79
|
+
new RegExp(
|
|
80
|
+
`^${SUBJECT}? ?(?:works|work|is working|works correctly|works properly|works as expected|functions correctly|functions as expected)$`
|
|
81
|
+
),
|
|
82
|
+
new RegExp(
|
|
83
|
+
`^${SUBJECT}? ?(?:behaves|performs|renders|displays|loads|responds) (?:correctly|properly|as expected|fine|ok|okay)$`
|
|
84
|
+
),
|
|
85
|
+
new RegExp(
|
|
86
|
+
`^${SUBJECT}? ?(?:is|looks|seems) (?:good|right|correct|fine|ok|okay|acceptable|reasonable|as expected|better|improved)$`
|
|
87
|
+
),
|
|
88
|
+
new RegExp(`^${SUBJECT}? ?(?:is|are)? ?(?:successful|success|succeeds|passes|passed)$`),
|
|
89
|
+
/^(?:everything|all of it|all) (?:works|is fine|is ok|is okay|is correct|looks good)$/,
|
|
90
|
+
/^no (?:errors|issues|problems|regressions|bugs)(?: (?:occur|appear|are seen|are observed|are present))?$/,
|
|
91
|
+
/^(?:performance|it) (?:is|feels) (?:acceptable|fast|good|fine|snappy|better|improved)$/,
|
|
92
|
+
/^(?:there (?:is|are) )?no (?:visual )?(?:change|changes|difference|differences)$/,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A `where` that names no surface.
|
|
97
|
+
*
|
|
98
|
+
* The field asks for a "Surface, environment, account, or role". A bare
|
|
99
|
+
* environment ("staging", "production") is a legitimate answer and is NOT
|
|
100
|
+
* listed here; a whole-product noun is not an answer at all, because it does
|
|
101
|
+
* not tell the next reviewer where to stand.
|
|
102
|
+
*/
|
|
103
|
+
const UNLOCATED_WHERE = [
|
|
104
|
+
/^(?:the )?(?:app|application|product|system|platform|site|website|service|ui|frontend|front end|backend|back end|client|server)$/,
|
|
105
|
+
/^(?:the )?(?:code|codebase|repo|repository|source)$/,
|
|
106
|
+
// `n/a` and friends are deliberately absent: PLACEHOLDER already names them,
|
|
107
|
+
// and one field earning two findings for one word reads as the check
|
|
108
|
+
// stuttering rather than as two problems.
|
|
109
|
+
/^(?:it|this|there|here|everywhere|anywhere|all surfaces|any surface|none|various)$/,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** Text nobody has written yet, left in a field that is required to be written. */
|
|
113
|
+
// WIP limit(s) names the board feature, not unfinished prose. Exempt only that
|
|
114
|
+
// noun phrase; bare WIP and separate placeholders in the same field still fire.
|
|
115
|
+
const PLACEHOLDER =
|
|
116
|
+
/(?:^|[^a-z0-9])(?:tbd|tba|todo|fixme|wip(?![\s-]+limits?\b)|xxx|placeholder|n\/a)(?:[^a-z0-9]|$)|\?{3,}|<[^>]{0,40}>/i;
|
|
117
|
+
|
|
118
|
+
const PART_LABELS = {
|
|
119
|
+
whereText: 'where',
|
|
120
|
+
givenText: 'given',
|
|
121
|
+
whenText: 'when',
|
|
122
|
+
thenText: 'then',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Inspect one criterion. Returns zero or more findings; never throws.
|
|
127
|
+
*
|
|
128
|
+
* @param {{whereText?: string, givenText?: string, whenText?: string, thenText?: string}} criterion
|
|
129
|
+
* @param {{ordinal?: number, id?: string}} context
|
|
130
|
+
*/
|
|
131
|
+
export function inspectCriterion(criterion, { ordinal = null, id = null } = {}) {
|
|
132
|
+
const findings = [];
|
|
133
|
+
const at = { criterionId: id, ordinal };
|
|
134
|
+
|
|
135
|
+
const thenText = normalise(criterion?.thenText);
|
|
136
|
+
if (thenText && UNFALSIFIABLE_THEN.some((pattern) => pattern.test(thenText))) {
|
|
137
|
+
findings.push({
|
|
138
|
+
...at,
|
|
139
|
+
code: 'unfalsifiable_then',
|
|
140
|
+
field: 'thenText',
|
|
141
|
+
text: String(criterion.thenText).trim(),
|
|
142
|
+
detail:
|
|
143
|
+
'The observable result asserts nothing a reviewer could disagree with, so this criterion cannot fail. Name what is on the screen or in the response — a value, a count, a state, an exact message.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const whereText = normalise(criterion?.whereText);
|
|
148
|
+
if (whereText && UNLOCATED_WHERE.some((pattern) => pattern.test(whereText))) {
|
|
149
|
+
findings.push({
|
|
150
|
+
...at,
|
|
151
|
+
code: 'unlocated_where',
|
|
152
|
+
field: 'whereText',
|
|
153
|
+
text: String(criterion.whereText).trim(),
|
|
154
|
+
detail:
|
|
155
|
+
'This names the whole product rather than a place to stand. Give the surface, environment, account, or role the next reviewer should check it on.',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const [field, label] of Object.entries(PART_LABELS)) {
|
|
160
|
+
const raw = criterion?.[field];
|
|
161
|
+
if (typeof raw === 'string' && raw.trim() && PLACEHOLDER.test(raw)) {
|
|
162
|
+
findings.push({
|
|
163
|
+
...at,
|
|
164
|
+
code: 'placeholder_text',
|
|
165
|
+
field,
|
|
166
|
+
text: raw.trim(),
|
|
167
|
+
detail: `The ${label} clause still carries a placeholder. A criterion nobody has finished writing cannot be executed by somebody who did not write the code.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return findings;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Inspect a whole list.
|
|
177
|
+
*
|
|
178
|
+
* The `vacuous` flag exists because `clean` is derived from an empty finding
|
|
179
|
+
* list, and an empty INPUT produces an empty finding list too. A caller that
|
|
180
|
+
* read `clean` alone would report a card with no criteria at all as a card
|
|
181
|
+
* whose criteria are in order — the same false green the board audit's
|
|
182
|
+
* coverage floor exists to stop.
|
|
183
|
+
*
|
|
184
|
+
* @param {Array<object>} criteria
|
|
185
|
+
*/
|
|
186
|
+
export function inspectCriteria(criteria) {
|
|
187
|
+
const list = Array.isArray(criteria) ? criteria : [];
|
|
188
|
+
const findings = [];
|
|
189
|
+
for (const [index, criterion] of list.entries()) {
|
|
190
|
+
findings.push(
|
|
191
|
+
...inspectCriterion(criterion, { ordinal: index + 1, id: criterion?.id ?? null })
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
checked: list.length,
|
|
196
|
+
vacuous: list.length === 0,
|
|
197
|
+
clean: list.length > 0 && findings.length === 0,
|
|
198
|
+
findings,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The one-line advisory a tool puts beside its own payload, or `undefined` when
|
|
204
|
+
* there is nothing to say. `undefined` rather than a cheerful "all good" so the
|
|
205
|
+
* note only ever appears when it carries information.
|
|
206
|
+
*/
|
|
207
|
+
export function describeCriteriaQuality(criteria) {
|
|
208
|
+
const report = inspectCriteria(criteria);
|
|
209
|
+
if (report.vacuous || report.clean) return undefined;
|
|
210
|
+
const codes = [...new Set(report.findings.map((finding) => finding.code))].join(', ');
|
|
211
|
+
return `${report.findings.length} of ${report.checked} criterion check(s) look unexecutable (${codes}). These are advisory, not a refusal: see \`criteriaQuality.findings\`. A criterion that cannot fail is not a definition of done.`;
|
|
212
|
+
}
|
|
@@ -7,6 +7,34 @@
|
|
|
7
7
|
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { md, mdError, table, formatDate } from '../output-formatter.js';
|
|
10
|
+
import { validateApiBridgeRequest } from '../api-bridge.js';
|
|
11
|
+
import { governedWriteArgs, previewMarkdown } from '../governance/governed-args.js';
|
|
12
|
+
|
|
13
|
+
// The real handlers create a fresh row before enqueueing. A failed enqueue
|
|
14
|
+
// returns non-2xx, which the shared middleware does not cache as completed;
|
|
15
|
+
// its 24h in-progress reservation still blocks same-key retries. Neither
|
|
16
|
+
// handler has durable request/job deduplication after expiry or a new key.
|
|
17
|
+
// Do not turn the old dead routes into outbound side effects until that
|
|
18
|
+
// failed-first-attempt contract is repaired and independently replayed.
|
|
19
|
+
const REPLAY_BLOCKER = 'Live calls and SMS are unavailable through this tool until durable request/job replay is verified. '
|
|
20
|
+
+ 'The API inserts a record before queueing; an enqueue failure can leave a partial result and a 24-hour in-progress key. '
|
|
21
|
+
+ 'A new or expired key can duplicate that work. Nothing was sent; do not bypass this hold with the raw API bridge.';
|
|
22
|
+
|
|
23
|
+
function previewCommunication(args, heading, request) {
|
|
24
|
+
try {
|
|
25
|
+
const validation = validateApiBridgeRequest({ ...args, method: request.method, path: request.path });
|
|
26
|
+
if (validation.dryRun) {
|
|
27
|
+
return md(previewMarkdown(heading, {
|
|
28
|
+
...validation, wouldSend: false, body: request.body, notes: request.notes, blocked: true, reason: REPLAY_BLOCKER,
|
|
29
|
+
fix: 'Backend durable request/job replay and independent failed-first-attempt verification are required.',
|
|
30
|
+
note: 'Approval fields do not lift this safety hold. This tool currently provides previews only.',
|
|
31
|
+
}, { executionAvailable: false }));
|
|
32
|
+
}
|
|
33
|
+
return { ...mdError(`${heading} unavailable`, REPLAY_BLOCKER), isError: true };
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { ...mdError(`${heading} refused`, err.message), isError: true };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
10
38
|
|
|
11
39
|
export function register(server, api, AUTH) {
|
|
12
40
|
|
|
@@ -15,52 +43,19 @@ export function register(server, api, AUTH) {
|
|
|
15
43
|
// -----------------------------------------------------------------------
|
|
16
44
|
server.tool(
|
|
17
45
|
'make_call',
|
|
18
|
-
'
|
|
46
|
+
'Preview a call request. Live execution is held until durable request/job replay is verified; approval does not bypass the hold.',
|
|
19
47
|
{
|
|
20
48
|
phoneNumber: z.string().describe('Phone number to dial'),
|
|
21
49
|
personId: z.string().optional().describe('Person ID for CRM linking'),
|
|
22
50
|
companyId: z.string().optional().describe('Company ID for CRM linking'),
|
|
23
|
-
notes: z.string().optional().describe('Pre-call notes or
|
|
51
|
+
notes: z.string().optional().describe('Pre-call notes shown in this preview only; not persisted or sent to the calling API'),
|
|
52
|
+
...governedWriteArgs(z),
|
|
24
53
|
},
|
|
25
|
-
async (args) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
personId: args.personId,
|
|
31
|
-
companyId: args.companyId,
|
|
32
|
-
notes: args.notes,
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const c = result?.data || result || {};
|
|
37
|
-
let text = `## Call Initiated\n\n`;
|
|
38
|
-
text += `- **To:** ${args.phoneNumber}\n`;
|
|
39
|
-
text += `- **Call ID:** ${c.id || c.callId || '\u2014'}\n`;
|
|
40
|
-
text += `- **Status:** ${c.status || 'Connecting...'}\n`;
|
|
41
|
-
if (args.personId) text += `- **Linked to person:** ${args.personId}\n`;
|
|
42
|
-
if (args.notes) text += `\n### Talking Points\n${args.notes}\n`;
|
|
43
|
-
text += '\nAfter the call, use `get_call_transcript` to get the AI summary.\n';
|
|
44
|
-
|
|
45
|
-
// Log action
|
|
46
|
-
if (args.personId) {
|
|
47
|
-
await api('POST', '/api/v1/actions', {
|
|
48
|
-
body: {
|
|
49
|
-
title: `Call to ${args.phoneNumber}`,
|
|
50
|
-
type: 'call',
|
|
51
|
-
personId: args.personId,
|
|
52
|
-
companyId: args.companyId,
|
|
53
|
-
status: 'IN_PROGRESS',
|
|
54
|
-
metadata: { callId: c.id || c.callId, phoneNumber: args.phoneNumber },
|
|
55
|
-
},
|
|
56
|
-
}).catch(() => {});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return md(text);
|
|
60
|
-
} catch (err) {
|
|
61
|
-
return mdError('Call failed', err.message);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
54
|
+
async (args) => previewCommunication(args, 'Call request', {
|
|
55
|
+
method: 'POST', path: '/api/v1/calling/dial',
|
|
56
|
+
body: { phoneNumber: args.phoneNumber, personId: args.personId, companyId: args.companyId },
|
|
57
|
+
notes: args.notes,
|
|
58
|
+
})
|
|
64
59
|
);
|
|
65
60
|
|
|
66
61
|
// -----------------------------------------------------------------------
|
|
@@ -68,34 +63,17 @@ export function register(server, api, AUTH) {
|
|
|
68
63
|
// -----------------------------------------------------------------------
|
|
69
64
|
server.tool(
|
|
70
65
|
'send_sms',
|
|
71
|
-
'
|
|
66
|
+
'Preview an SMS request. Live execution is held until durable request/job replay is verified; approval does not bypass the hold.',
|
|
72
67
|
{
|
|
73
68
|
to: z.string().describe('Recipient phone number'),
|
|
74
69
|
message: z.string().describe('SMS message text'),
|
|
75
70
|
personId: z.string().optional().describe('Person ID for CRM linking'),
|
|
71
|
+
...governedWriteArgs(z),
|
|
76
72
|
},
|
|
77
|
-
async (args) => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
to: args.to,
|
|
82
|
-
message: args.message,
|
|
83
|
-
personId: args.personId,
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const r = result?.data || result || {};
|
|
88
|
-
let text = `## SMS Sent\n\n`;
|
|
89
|
-
text += `- **To:** ${args.to}\n`;
|
|
90
|
-
text += `- **Message:** ${args.message}\n`;
|
|
91
|
-
text += `- **Status:** ${r.status || 'Sent'}\n`;
|
|
92
|
-
if (r.id) text += `- **ID:** ${r.id}\n`;
|
|
93
|
-
|
|
94
|
-
return md(text);
|
|
95
|
-
} catch (err) {
|
|
96
|
-
return mdError('SMS failed', err.message);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
73
|
+
async (args) => previewCommunication(args, 'SMS request', {
|
|
74
|
+
method: 'POST', path: '/api/v1/sms/send',
|
|
75
|
+
body: { toNumber: args.to, body: args.message, personId: args.personId },
|
|
76
|
+
})
|
|
99
77
|
);
|
|
100
78
|
|
|
101
79
|
// -----------------------------------------------------------------------
|
package/toolsets/spaces.js
CHANGED
|
@@ -182,8 +182,17 @@ export function describeCount({ found, of, noun, ofNoun, shapeRecognized = true,
|
|
|
182
182
|
typeof of === 'number'
|
|
183
183
|
? `${found} of ${of} ${ofNoun ?? noun}`
|
|
184
184
|
: `${found} ${noun}${truncated ? ' (page limit reached — more exist)' : ''}`;
|
|
185
|
+
// Three DIFFERENT zeros, and they lead a reader to three different actions. The first two
|
|
186
|
+
// used to be exchanged: a zero measured against rows elsewhere announced the workspace was
|
|
187
|
+
// empty, and a zero measured against a stated zero denominator announced there was no
|
|
188
|
+
// denominator. Each contradicted the count printed immediately before it, which is why
|
|
189
|
+
// neither was caught — the number was right and only the sentence was wrong, and the
|
|
190
|
+
// sentence is the part that gets acted on.
|
|
185
191
|
if (found === 0 && typeof of === 'number' && of > 0) {
|
|
186
|
-
return `${base}. The store
|
|
192
|
+
return `${base}. The store does hold ${of} ${ofNoun ?? noun} — none of them match this scope. This is a real zero for this filter, NOT an empty store; widen the scope rather than reporting that there is nothing.`;
|
|
193
|
+
}
|
|
194
|
+
if (found === 0 && of === 0) {
|
|
195
|
+
return `${base}. The denominator was read and it is zero: the store is reachable and holds nothing for this workspace at all. This is an UNWRITTEN store, not evidence that the thing being counted does not happen.`;
|
|
187
196
|
}
|
|
188
197
|
if (found === 0) {
|
|
189
198
|
return `${base}. Zero rows AND no denominator available, so this cannot distinguish an empty workspace from an undeployed store — say so rather than reporting "none".`;
|