create-byan-agent 2.29.2 → 2.38.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/CHANGELOG.md +361 -0
- package/install/bin/create-byan-agent-v2.js +44 -1
- package/install/lib/claude-native-setup.js +12 -38
- package/install/lib/gdoc-setup.js +210 -0
- package/install/lib/mcp-extensions/gdrive.js +27 -2
- package/install/lib/platforms/claude-code.js +28 -19
- package/install/lib/rtk-integration.js +18 -8
- package/install/package.json +1 -1
- package/install/packages/platform-config/lib/credentials.js +13 -1
- package/install/packages/platform-config/lib/mcp-config.js +71 -5
- package/install/setup-gdoc.js +41 -0
- package/install/setup-rtk.js +1 -1
- package/install/templates/.claude/CLAUDE.md +16 -4
- package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
- package/install/templates/.claude/hooks/inject-soul.js +4 -3
- package/install/templates/.claude/hooks/inject-tao.js +65 -25
- package/install/templates/.claude/hooks/inject-voice-anchor.js +102 -0
- package/install/templates/.claude/hooks/leantime-fd-sync.js +12 -1
- package/install/templates/.claude/hooks/lib/delivery-contract.js +143 -0
- package/install/templates/.claude/hooks/lib/punt-detect.js +143 -0
- package/install/templates/.claude/hooks/punt-guard.js +126 -0
- package/install/templates/.claude/hooks/strict-stop-guard.js +29 -6
- package/install/templates/.claude/rules/portable-core.md +81 -0
- package/install/templates/.claude/settings.json +13 -1
- package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +16 -2
- package/install/templates/_byan/_config/delivery-default.json +22 -0
- package/install/templates/_byan/mcp/byan-mcp-server/channel-entry.js +46 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-poll.js +128 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-server.js +234 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/completeness-evidence.js +159 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-client.js +203 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-content.js +203 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +60 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +4 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +68 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/resolve-config.js +15 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +1 -1
- package/install/templates/_byan/mcp/byan-mcp-server/package.json +2 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +70 -0
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
- package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
- package/install/templates/docs/google-docs-publish.md +121 -0
- package/install/templates/docs/leantime-integration.md +11 -1
- package/node_modules/byan-platform-config/lib/credentials.js +13 -1
- package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
- package/package.json +3 -1
- package/install/templates/.mcp.json.tmpl +0 -8
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// gdoc-content -- PURE content -> Google Docs API request builders.
|
|
2
|
+
//
|
|
3
|
+
// No network, no googleapis, no I/O : this module turns a plain content object
|
|
4
|
+
// into the deterministic arrays of Docs `batchUpdate` requests that gdoc-client
|
|
5
|
+
// sends. Kept pure so the whole shaping logic is unit-tested without a service
|
|
6
|
+
// account or a network (see test/gdoc-content.test.js).
|
|
7
|
+
//
|
|
8
|
+
// Two modes, mirroring gdoc-client's two paths :
|
|
9
|
+
// - TEMPLATE mode : buildReplaceRequests() -> replaceAllText over a branded
|
|
10
|
+
// template Doc (logo + palette live IN the template ; we only fill text).
|
|
11
|
+
// - NO-TEMPLATE : buildDocumentRequests() -> insert a structured text doc
|
|
12
|
+
// and colour the title + headings from the AcadeNice palette. A logo is
|
|
13
|
+
// inserted at the top when a PNG URL is provided (opts.logoPngUrl /
|
|
14
|
+
// GDOC_LOGO_PNG_URL) ; Docs accepts a raster URL (PNG/JPG/GIF), not SVG, so
|
|
15
|
+
// the URL must point at a PNG. Absent -> the doc is text branded by colour
|
|
16
|
+
// only (graceful, no image request).
|
|
17
|
+
|
|
18
|
+
// AcadeNice palette (from the brand charter). Hex here ; converted to the Docs
|
|
19
|
+
// API rgbColor (0..1 floats) by hexToRgbColor.
|
|
20
|
+
const BRANDING = {
|
|
21
|
+
marine: '#0e2656',
|
|
22
|
+
teal: '#24947a',
|
|
23
|
+
turquoise: '#4cccb8',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* hexToRgbColor('#0e2656') -> { red, green, blue } each in [0,1], the shape the
|
|
28
|
+
* Docs API expects under textStyle.foregroundColor.color.rgbColor. Accepts an
|
|
29
|
+
* optional leading '#' and 3- or 6-digit hex. Throws on malformed input (a bad
|
|
30
|
+
* palette constant is a programming error, surfaced loudly in tests).
|
|
31
|
+
* @param {string} hex
|
|
32
|
+
*/
|
|
33
|
+
function hexToRgbColor(hex) {
|
|
34
|
+
if (typeof hex !== 'string') throw new TypeError('hex must be a string');
|
|
35
|
+
let h = hex.trim().replace(/^#/, '');
|
|
36
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
37
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) throw new Error(`invalid hex colour: ${hex}`);
|
|
38
|
+
const n = parseInt(h, 16);
|
|
39
|
+
return {
|
|
40
|
+
red: ((n >> 16) & 255) / 255,
|
|
41
|
+
green: ((n >> 8) & 255) / 255,
|
|
42
|
+
blue: (n & 255) / 255,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize + validate the content object. Returns { title, sections, resources,
|
|
48
|
+
* fields }. Throws a clear error when the minimum (a non-empty title) is missing
|
|
49
|
+
* -- the caller turns that into a tool error, never a crash.
|
|
50
|
+
* @param {object} content
|
|
51
|
+
*/
|
|
52
|
+
function normalizeContent(content) {
|
|
53
|
+
const c = content || {};
|
|
54
|
+
const title = typeof c.title === 'string' ? c.title.trim() : '';
|
|
55
|
+
if (!title) throw new Error('content.title is required (non-empty string)');
|
|
56
|
+
const sections = Array.isArray(c.sections)
|
|
57
|
+
? c.sections
|
|
58
|
+
.map((s) => ({
|
|
59
|
+
heading: typeof s?.heading === 'string' ? s.heading.trim() : '',
|
|
60
|
+
body: typeof s?.body === 'string' ? s.body : '',
|
|
61
|
+
}))
|
|
62
|
+
.filter((s) => s.heading || s.body)
|
|
63
|
+
: [];
|
|
64
|
+
const resources = Array.isArray(c.resources)
|
|
65
|
+
? c.resources
|
|
66
|
+
.map((r) => ({
|
|
67
|
+
label: typeof r?.label === 'string' ? r.label.trim() : '',
|
|
68
|
+
url: typeof r?.url === 'string' ? r.url.trim() : '',
|
|
69
|
+
}))
|
|
70
|
+
.filter((r) => r.label || r.url)
|
|
71
|
+
: [];
|
|
72
|
+
const fields = c.fields && typeof c.fields === 'object' ? c.fields : {};
|
|
73
|
+
return { title, sections, resources, fields };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* TEMPLATE mode. Map the content onto {{PLACEHOLDER}} tokens and emit one
|
|
78
|
+
* replaceAllText request per token. The template Doc carries the branding ; we
|
|
79
|
+
* only substitute text. `fields` lets a caller fill arbitrary {{KEY}} tokens
|
|
80
|
+
* (e.g. {{CANDIDAT}}). Deterministic order (stable for tests).
|
|
81
|
+
* @param {object} content
|
|
82
|
+
*/
|
|
83
|
+
function buildReplaceRequests(content) {
|
|
84
|
+
const { title, sections, resources, fields } = normalizeContent(content);
|
|
85
|
+
const map = {
|
|
86
|
+
TITLE: title,
|
|
87
|
+
BLOCS: sections.map((s) => `${s.heading}\n${s.body}`.trim()).join('\n\n'),
|
|
88
|
+
RESSOURCES: resources.map((r) => (r.url ? `${r.label} : ${r.url}` : r.label)).join('\n'),
|
|
89
|
+
};
|
|
90
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
91
|
+
if (v != null) map[String(k).toUpperCase()] = String(v);
|
|
92
|
+
}
|
|
93
|
+
return Object.entries(map).map(([key, value]) => ({
|
|
94
|
+
replaceAllText: {
|
|
95
|
+
containsText: { text: `{{${key}}}`, matchCase: true },
|
|
96
|
+
replaceText: value,
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Assemble the flat document text + the index ranges to colour, for NO-TEMPLATE
|
|
103
|
+
* mode. Docs is 1-indexed (index 1 = first insertable position), so every range
|
|
104
|
+
* is offset by +1 against the assembled string. Returns { text, titleRange,
|
|
105
|
+
* headingRanges } with ranges as { startIndex, endIndex } in Docs coordinates.
|
|
106
|
+
* @param {object} content
|
|
107
|
+
*/
|
|
108
|
+
function assembleDocument(content) {
|
|
109
|
+
const { title, sections, resources } = normalizeContent(content);
|
|
110
|
+
const headingRanges = [];
|
|
111
|
+
let text = '';
|
|
112
|
+
const DOC_START = 1; // Docs body first insertable index
|
|
113
|
+
|
|
114
|
+
const titleStart = DOC_START + text.length;
|
|
115
|
+
text += `${title}\n\n`;
|
|
116
|
+
const titleRange = { startIndex: titleStart, endIndex: titleStart + title.length };
|
|
117
|
+
|
|
118
|
+
for (const s of sections) {
|
|
119
|
+
if (s.heading) {
|
|
120
|
+
const hs = DOC_START + text.length;
|
|
121
|
+
text += `${s.heading}\n`;
|
|
122
|
+
headingRanges.push({ startIndex: hs, endIndex: hs + s.heading.length });
|
|
123
|
+
}
|
|
124
|
+
if (s.body) text += `${s.body}\n`;
|
|
125
|
+
text += '\n';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (resources.length) {
|
|
129
|
+
const rh = 'Ressources';
|
|
130
|
+
const rhs = DOC_START + text.length;
|
|
131
|
+
text += `${rh}\n`;
|
|
132
|
+
headingRanges.push({ startIndex: rhs, endIndex: rhs + rh.length });
|
|
133
|
+
for (const r of resources) {
|
|
134
|
+
text += `${r.url ? `${r.label} : ${r.url}` : r.label}\n`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { text, titleRange, headingRanges };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* NO-TEMPLATE mode. Insert the assembled text at index 1, then colour the title
|
|
143
|
+
* (marine, bold, larger) and each heading (teal, bold) from the palette. Indices
|
|
144
|
+
* are valid because the single insertText happens first and every style range is
|
|
145
|
+
* computed against that one block. Returns the batchUpdate requests array.
|
|
146
|
+
* @param {object} content
|
|
147
|
+
* @param {object} [opts]
|
|
148
|
+
* @param {object} [opts.branding=BRANDING]
|
|
149
|
+
* @param {string} [opts.logoPngUrl] PNG URL inserted at the top ; absent -> skipped
|
|
150
|
+
*/
|
|
151
|
+
function buildDocumentRequests(content, opts = {}) {
|
|
152
|
+
const branding = opts.branding || BRANDING;
|
|
153
|
+
const { text, titleRange, headingRanges } = assembleDocument(content);
|
|
154
|
+
|
|
155
|
+
const requests = [{ insertText: { location: { index: 1 }, text } }];
|
|
156
|
+
|
|
157
|
+
requests.push({
|
|
158
|
+
updateTextStyle: {
|
|
159
|
+
range: titleRange,
|
|
160
|
+
textStyle: {
|
|
161
|
+
bold: true,
|
|
162
|
+
fontSize: { magnitude: 20, unit: 'PT' },
|
|
163
|
+
foregroundColor: { color: { rgbColor: hexToRgbColor(branding.marine) } },
|
|
164
|
+
},
|
|
165
|
+
fields: 'bold,fontSize,foregroundColor',
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
for (const range of headingRanges) {
|
|
170
|
+
requests.push({
|
|
171
|
+
updateTextStyle: {
|
|
172
|
+
range,
|
|
173
|
+
textStyle: {
|
|
174
|
+
bold: true,
|
|
175
|
+
fontSize: { magnitude: 14, unit: 'PT' },
|
|
176
|
+
foregroundColor: { color: { rgbColor: hexToRgbColor(branding.teal) } },
|
|
177
|
+
},
|
|
178
|
+
fields: 'bold,fontSize,foregroundColor',
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Logo last : inserted at index 1 AFTER text+styles, so it lands at the very
|
|
184
|
+
// top and the already-styled text simply shifts down (styles stay bound to the
|
|
185
|
+
// characters). Skipped when no PNG URL is configured (graceful). Docs needs a
|
|
186
|
+
// raster URL (PNG/JPG/GIF) -- a non-PNG/SVG URL is rejected by the API and
|
|
187
|
+
// surfaces as gdoc-client's api-error, not a crash here.
|
|
188
|
+
const logoPngUrl = typeof opts.logoPngUrl === 'string' ? opts.logoPngUrl.trim() : '';
|
|
189
|
+
if (logoPngUrl) {
|
|
190
|
+
requests.push({ insertInlineImage: { location: { index: 1 }, uri: logoPngUrl } });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return requests;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export {
|
|
197
|
+
BRANDING,
|
|
198
|
+
hexToRgbColor,
|
|
199
|
+
normalizeContent,
|
|
200
|
+
buildReplaceRequests,
|
|
201
|
+
assembleDocument,
|
|
202
|
+
buildDocumentRequests,
|
|
203
|
+
};
|
|
@@ -70,6 +70,47 @@ const PHASE_RANK = {
|
|
|
70
70
|
ABORTED: 9,
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
// Map an FD backlog item's priority (P1/P2/P3) to a Leantime priority int.
|
|
74
|
+
// Leantime priority is high=3 .. low=1; an unknown priority yields undefined so
|
|
75
|
+
// the caller can OMIT the key (an absent priority must not be forced to a value).
|
|
76
|
+
export function priorityToLeantime(priority) {
|
|
77
|
+
switch (priority) {
|
|
78
|
+
case 'P1':
|
|
79
|
+
return 3;
|
|
80
|
+
case 'P2':
|
|
81
|
+
return 2;
|
|
82
|
+
case 'P3':
|
|
83
|
+
return 1;
|
|
84
|
+
default:
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Map an FD backlog item to a Leantime story-points effort estimate. Prefers a
|
|
90
|
+
// finite numeric `complexity` (0-100 scale, bucketed onto the Fibonacci scale
|
|
91
|
+
// Leantime uses for estimates), and falls back to the coarse priority signal
|
|
92
|
+
// when complexity is absent. ALWAYS returns a number so the caller can post a
|
|
93
|
+
// non-null estimate on every created task.
|
|
94
|
+
export function complexityToStorypoints(item) {
|
|
95
|
+
if (item && Number.isFinite(item.complexity)) {
|
|
96
|
+
const c = item.complexity;
|
|
97
|
+
if (c <= 15) return 2;
|
|
98
|
+
if (c <= 39) return 5;
|
|
99
|
+
if (c <= 69) return 8;
|
|
100
|
+
return 13;
|
|
101
|
+
}
|
|
102
|
+
switch (item && item.priority) {
|
|
103
|
+
case 'P1':
|
|
104
|
+
return 8;
|
|
105
|
+
case 'P2':
|
|
106
|
+
return 5;
|
|
107
|
+
case 'P3':
|
|
108
|
+
return 3;
|
|
109
|
+
default:
|
|
110
|
+
return 3;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
73
114
|
function lastReviewStatus(state) {
|
|
74
115
|
const arr = Array.isArray(state.review_findings) ? state.review_findings : [];
|
|
75
116
|
for (let i = arr.length - 1; i >= 0; i -= 1) {
|
|
@@ -126,7 +167,7 @@ export function columnForState(state) {
|
|
|
126
167
|
// Intent ops (the shell maps each to a leantime-sync call):
|
|
127
168
|
// { op:'project_ensure', name, slug, details }
|
|
128
169
|
// { op:'assign_user' } // only if configured (shell sequences it after project_ensure)
|
|
129
|
-
// { op:'task_create', backlogId, headline }
|
|
170
|
+
// { op:'task_create', backlogId, headline, storypoints, description, priority? }
|
|
130
171
|
// { op:'task_move', backlogId, column }
|
|
131
172
|
export function decideActions({ toolName, state, sidecar = {}, assignUserConfigured = false } = {}) {
|
|
132
173
|
const kind = fdToolKind(toolName);
|
|
@@ -161,7 +202,24 @@ export function decideActions({ toolName, state, sidecar = {}, assignUserConfigu
|
|
|
161
202
|
if (!item || !item.id) continue;
|
|
162
203
|
if (item.status === 'skipped') continue;
|
|
163
204
|
if (!tasks[item.id]) {
|
|
164
|
-
|
|
205
|
+
const headline = item.title || item.id;
|
|
206
|
+
// Enrich the create with effort + priority + a traceable description so
|
|
207
|
+
// the board item carries the BYAN signal, not just a bare title.
|
|
208
|
+
const priority = priorityToLeantime(item.priority);
|
|
209
|
+
const hasComplexity = Number.isFinite(item.complexity);
|
|
210
|
+
const description =
|
|
211
|
+
`BYAN FD ${state.fd_id || ''} -- ${headline}`.trim() +
|
|
212
|
+
(hasComplexity ? ` [complexity:${item.complexity}]` : '');
|
|
213
|
+
const intent = {
|
|
214
|
+
op: 'task_create',
|
|
215
|
+
backlogId: item.id,
|
|
216
|
+
headline,
|
|
217
|
+
storypoints: complexityToStorypoints(item),
|
|
218
|
+
description,
|
|
219
|
+
};
|
|
220
|
+
// priority is OMITTED when unknown (do not force an absent priority).
|
|
221
|
+
if (priority !== undefined) intent.priority = priority;
|
|
222
|
+
intents.push(intent);
|
|
165
223
|
}
|
|
166
224
|
}
|
|
167
225
|
}
|
|
@@ -216,7 +216,7 @@ export async function ensureProject({ name, slug, clientId, details } = {}, opts
|
|
|
216
216
|
// `id` so the caller can store it back into fd-state (idempotency lives in the
|
|
217
217
|
// caller: create-only-if the backlog item has no leantime_task_id yet).
|
|
218
218
|
export async function createTask(
|
|
219
|
-
{ projectId, headline, description, status, priority, editorId, tags, type = 'task' } = {},
|
|
219
|
+
{ projectId, headline, description, status, priority, storypoints, editorId, tags, type = 'task' } = {},
|
|
220
220
|
opts = {},
|
|
221
221
|
) {
|
|
222
222
|
if (!projectId) return { ok: false, synced: false, reason: 'no_project_id' };
|
|
@@ -226,6 +226,9 @@ export async function createTask(
|
|
|
226
226
|
if (description != null) values.description = description;
|
|
227
227
|
if (status != null) values.status = status;
|
|
228
228
|
if (priority != null) values.priority = priority;
|
|
229
|
+
// storypoints is the presumed Leantime effort field (UNVERIFIED against a live
|
|
230
|
+
// instance); an unknown key is at worst ignored by addTicket, so this is safe.
|
|
231
|
+
if (storypoints != null) values.storypoints = storypoints;
|
|
229
232
|
// Default the assignee to the configured human (LEANTIME_ASSIGN_USER_ID) so an
|
|
230
233
|
// auto-created task shows on a person's board, not only the API service user.
|
|
231
234
|
const resolvedEditor = editorId != null ? editorId : assignUserId();
|
|
@@ -1,6 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { getStatus, MIN_PASSES } from './strict-mode.js';
|
|
2
4
|
import { fetchSession, syncEnabled } from './strict-sync.js';
|
|
3
5
|
|
|
6
|
+
// F2 (additive): the completeness-evidence gate at commit time. Reads the
|
|
7
|
+
// completeness ledger written by strict complete(). It only ever BLOCKS when
|
|
8
|
+
// delivery-default.json completenessGate.armed === true (default false). With
|
|
9
|
+
// the gate disarmed this is a no-op and the gate decision is exactly as before.
|
|
10
|
+
const DELIVERY_CONFIG_REL = path.join('_byan', '_config', 'delivery-default.json');
|
|
11
|
+
const COMPLETENESS_LEDGER_REL = path.join('_byan-output', 'completeness-ledger.jsonl');
|
|
12
|
+
|
|
13
|
+
function completenessGateArmed(projectRoot) {
|
|
14
|
+
try {
|
|
15
|
+
const p = path.join(projectRoot || process.cwd(), DELIVERY_CONFIG_REL);
|
|
16
|
+
if (!fs.existsSync(p)) return false;
|
|
17
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
18
|
+
return !!(cfg && cfg.completenessGate && cfg.completenessGate.armed === true);
|
|
19
|
+
} catch {
|
|
20
|
+
return false; // A broken/absent config never arms the gate.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Last completeness-ledger entry for the given scope_hash, or null. Best-effort.
|
|
25
|
+
function lastCompletenessEntry(projectRoot, scopeHash) {
|
|
26
|
+
try {
|
|
27
|
+
const p = path.join(projectRoot || process.cwd(), COMPLETENESS_LEDGER_REL);
|
|
28
|
+
if (!fs.existsSync(p)) return null;
|
|
29
|
+
const lines = fs
|
|
30
|
+
.readFileSync(p, 'utf8')
|
|
31
|
+
.split('\n')
|
|
32
|
+
.filter((l) => l.trim());
|
|
33
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
34
|
+
try {
|
|
35
|
+
const e = JSON.parse(lines[i]);
|
|
36
|
+
if (!scopeHash || e.scope_hash === scopeHash) return e;
|
|
37
|
+
} catch {
|
|
38
|
+
// skip a malformed line
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// unreadable ledger -> treat as no evidence record
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Pure overlay: given a base decision (PASS), apply the armed completeness gate.
|
|
48
|
+
// Returns the base decision untouched when disarmed or when the base already
|
|
49
|
+
// blocks; otherwise blocks when the completeness ledger shows missing criteria.
|
|
50
|
+
export function applyCompletenessGate(base, { armed, entry }) {
|
|
51
|
+
if (!base.pass || !armed) return base;
|
|
52
|
+
if (entry && Array.isArray(entry.missing) && entry.missing.length) {
|
|
53
|
+
return {
|
|
54
|
+
pass: false,
|
|
55
|
+
reason:
|
|
56
|
+
`Completeness gate ARMED: the completed strict session left ` +
|
|
57
|
+
`${entry.missing.length} code-shaped criteria without evidence ` +
|
|
58
|
+
`(${entry.missing.join('; ')}). Provide the artifacts before committing.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return base;
|
|
62
|
+
}
|
|
63
|
+
|
|
4
64
|
// BYAN Strict Mode pre-commit gate.
|
|
5
65
|
//
|
|
6
66
|
// The final, platform-agnostic net. Claude Code has in-session hooks; Codex
|
|
@@ -104,6 +164,7 @@ export async function evaluateGate({ projectRoot, fetchImpl } = {}) {
|
|
|
104
164
|
const local = getStatus({ projectRoot });
|
|
105
165
|
const normalizedLocal = normalizeLocal(local);
|
|
106
166
|
|
|
167
|
+
let base;
|
|
107
168
|
// Consult the authority when there is a session to check and a token is set.
|
|
108
169
|
if (normalizedLocal.sessionId && syncEnabled()) {
|
|
109
170
|
const remote = await fetchSession(
|
|
@@ -111,10 +172,15 @@ export async function evaluateGate({ projectRoot, fetchImpl } = {}) {
|
|
|
111
172
|
fetchImpl ? { fetchImpl } : {}
|
|
112
173
|
);
|
|
113
174
|
if (remote.ok && remote.data) {
|
|
114
|
-
|
|
175
|
+
base = decide(normalizeApi(remote.data));
|
|
115
176
|
}
|
|
116
177
|
// API unreachable / not found -> fall back to local mirror.
|
|
117
178
|
}
|
|
179
|
+
if (!base) base = decide(normalizedLocal);
|
|
118
180
|
|
|
119
|
-
|
|
181
|
+
// F2 overlay: disarmed by default -> returns `base` unchanged.
|
|
182
|
+
const armed = completenessGateArmed(projectRoot);
|
|
183
|
+
if (!armed) return base;
|
|
184
|
+
const entry = lastCompletenessEntry(projectRoot, local.scope_hash);
|
|
185
|
+
return applyCompletenessGate(base, { armed, entry });
|
|
120
186
|
}
|
|
@@ -26,8 +26,18 @@ import nodePath from 'node:path';
|
|
|
26
26
|
const DEFAULT_API_URL = 'http://localhost:3737';
|
|
27
27
|
|
|
28
28
|
// Keys the resolver understands. BYAN_API_URL is the only one with a non-empty
|
|
29
|
-
// default; tokens
|
|
30
|
-
|
|
29
|
+
// default; tokens, the optional Leantime URL, and the optional Google Docs
|
|
30
|
+
// publish config (service-account key path, template id, logo URL) stay empty
|
|
31
|
+
// when unset.
|
|
32
|
+
const KEYS = [
|
|
33
|
+
'BYAN_API_URL',
|
|
34
|
+
'BYAN_API_TOKEN',
|
|
35
|
+
'LEANTIME_API_URL',
|
|
36
|
+
'LEANTIME_API_TOKEN',
|
|
37
|
+
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
38
|
+
'GDOC_TEMPLATE_ID',
|
|
39
|
+
'GDOC_LOGO_PNG_URL',
|
|
40
|
+
];
|
|
31
41
|
|
|
32
42
|
/**
|
|
33
43
|
* An unexpanded `${...}` placeholder is NOT a real value. It is what a launcher
|
|
@@ -98,6 +108,9 @@ function resolveConfig(opts = {}) {
|
|
|
98
108
|
out.BYAN_API_TOKEN = out.BYAN_API_TOKEN || '';
|
|
99
109
|
out.LEANTIME_API_URL = out.LEANTIME_API_URL || '';
|
|
100
110
|
out.LEANTIME_API_TOKEN = out.LEANTIME_API_TOKEN || '';
|
|
111
|
+
out.GOOGLE_APPLICATION_CREDENTIALS = out.GOOGLE_APPLICATION_CREDENTIALS || '';
|
|
112
|
+
out.GDOC_TEMPLATE_ID = out.GDOC_TEMPLATE_ID || '';
|
|
113
|
+
out.GDOC_LOGO_PNG_URL = out.GDOC_LOGO_PNG_URL || '';
|
|
101
114
|
return out;
|
|
102
115
|
}
|
|
103
116
|
|
|
@@ -1,12 +1,50 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import { buildEvidence } from './completeness-evidence.js';
|
|
4
5
|
|
|
5
6
|
const STRICT_DIR = '.byan-strict';
|
|
6
7
|
const STATE_FILE = 'state.json';
|
|
7
8
|
const AUDIT_FILE = 'audit.log';
|
|
8
9
|
const MIN_SELF_VERIFY_PASSES = 3;
|
|
9
10
|
|
|
11
|
+
// F2 completeness-evidence gate. complete() ATTACHES an evidence report and, when
|
|
12
|
+
// the gate is ARMED (delivery-default.json completenessGate.armed === true),
|
|
13
|
+
// hard-rejects a completion that leaves code-shaped criteria without evidence.
|
|
14
|
+
// Default DISARMED: complete() behaves exactly as before (passCount >= 3 + last
|
|
15
|
+
// verdict ok), the report is pure observation, and every completion appends one
|
|
16
|
+
// line to the completeness ledger so arming later is data-informed.
|
|
17
|
+
const DELIVERY_CONFIG_REL = path.join('_byan', '_config', 'delivery-default.json');
|
|
18
|
+
const COMPLETENESS_LEDGER_REL = path.join('_byan-output', 'completeness-ledger.jsonl');
|
|
19
|
+
|
|
20
|
+
function readDeliveryConfig(projectRoot) {
|
|
21
|
+
try {
|
|
22
|
+
const p = path.join(resolveRoot(projectRoot), DELIVERY_CONFIG_REL);
|
|
23
|
+
if (fs.existsSync(p)) {
|
|
24
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
25
|
+
if (cfg && typeof cfg === 'object') return cfg;
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// A broken config must NOT arm the gate — fail safe toward disarmed.
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function completenessGateArmed(config) {
|
|
34
|
+
const g = config && config.completenessGate;
|
|
35
|
+
return !!(g && g.armed === true);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function appendCompletenessLedger(entry, projectRoot) {
|
|
39
|
+
try {
|
|
40
|
+
const p = path.join(resolveRoot(projectRoot), COMPLETENESS_LEDGER_REL);
|
|
41
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
42
|
+
fs.appendFileSync(p, JSON.stringify(entry) + '\n');
|
|
43
|
+
} catch {
|
|
44
|
+
// The observation ledger is best-effort — never break completion.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
10
48
|
function resolveRoot(projectRoot) {
|
|
11
49
|
return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
12
50
|
}
|
|
@@ -234,7 +272,7 @@ export function selfVerify({
|
|
|
234
272
|
};
|
|
235
273
|
}
|
|
236
274
|
|
|
237
|
-
export function complete({ projectRoot, now = new Date() } = {}) {
|
|
275
|
+
export function complete({ projectRoot, now = new Date(), context = {}, evidenceIo } = {}) {
|
|
238
276
|
const state = readState(projectRoot);
|
|
239
277
|
if (!state || !state.active) {
|
|
240
278
|
throw new Error('No active strict session.');
|
|
@@ -258,6 +296,42 @@ export function complete({ projectRoot, now = new Date() } = {}) {
|
|
|
258
296
|
);
|
|
259
297
|
}
|
|
260
298
|
|
|
299
|
+
// F2 (additive): collect the completeness-evidence report over the locked
|
|
300
|
+
// criteria + allowed paths. Always observed + ledgered; only ENFORCED when the
|
|
301
|
+
// gate is armed in delivery-default.json (default false -> behaviour unchanged).
|
|
302
|
+
const deliveryConfig = readDeliveryConfig(projectRoot);
|
|
303
|
+
const gateArmed = completenessGateArmed(deliveryConfig);
|
|
304
|
+
const evidence = buildEvidence({
|
|
305
|
+
criteria: state.scope_lock.acceptance_criteria || [],
|
|
306
|
+
allowedPaths: state.scope_lock.allowed_paths || [],
|
|
307
|
+
context,
|
|
308
|
+
projectRoot: resolveRoot(projectRoot),
|
|
309
|
+
io: evidenceIo,
|
|
310
|
+
});
|
|
311
|
+
appendCompletenessLedger(
|
|
312
|
+
{
|
|
313
|
+
ts: now.toISOString(),
|
|
314
|
+
event: gateArmed ? (evidence.missing.length ? 'would-reject' : 'pass') : 'observed-disarmed',
|
|
315
|
+
strict_session_id: state.strict_session_id,
|
|
316
|
+
scope_hash: state.scope_lock.scope_hash,
|
|
317
|
+
armed: gateArmed,
|
|
318
|
+
missing: evidence.missing,
|
|
319
|
+
per_criterion: evidence.perCriterion.map((c) => ({
|
|
320
|
+
criterion: c.criterion,
|
|
321
|
+
kind: c.kind,
|
|
322
|
+
hasEvidence: c.hasEvidence,
|
|
323
|
+
})),
|
|
324
|
+
},
|
|
325
|
+
projectRoot
|
|
326
|
+
);
|
|
327
|
+
if (gateArmed && evidence.missing.length) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Cannot complete: completeness gate is ARMED and ${evidence.missing.length} ` +
|
|
330
|
+
`code-shaped criteria have no evidence (file / green test / diff): ` +
|
|
331
|
+
`${evidence.missing.join('; ')}. Provide the missing artifacts or re-lock the scope.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
261
335
|
const auditEntries = readAuditLog(projectRoot);
|
|
262
336
|
const auditPayload = JSON.stringify({
|
|
263
337
|
scope_hash: state.scope_lock.scope_hash,
|
|
@@ -296,6 +370,9 @@ export function complete({ projectRoot, now = new Date() } = {}) {
|
|
|
296
370
|
// Surfaced for the C3 learning-loop feed: an explicit ELO domain on the
|
|
297
371
|
// locked scope means a completed session is a VALIDATED outcome.
|
|
298
372
|
domain: state.scope_lock.domain || '',
|
|
373
|
+
// F2 (additive): the completeness-evidence report. Attached whether or not
|
|
374
|
+
// the gate is armed; with the gate disarmed it is pure observation.
|
|
375
|
+
evidence,
|
|
299
376
|
};
|
|
300
377
|
}
|
|
301
378
|
|
|
@@ -271,7 +271,7 @@ both gates hold (>= 2 non-substitutable options diverging on >= 1 weighted
|
|
|
271
271
|
criterion). Emit the marker verbatim before the table:
|
|
272
272
|
\`<!-- BYAN-BENCH:done g1=<#options> g2=<#divergent-criteria> scope=<internal|external> conf=<assertive|lean> -->\`.
|
|
273
273
|
A confirm, a destructive prompt, or an obvious default is not a fork — emit
|
|
274
|
-
\`<!-- BYAN-BENCH:skip reason=.. -->\` instead. Full doctrine: see
|
|
274
|
+
\`<!-- BYAN-BENCH:skip reason=.. -->\` instead. Full doctrine (loaded on demand): see .claude/rules/benchmark.md`;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
// ---------------------------------------------------------------------------
|
|
@@ -14,6 +14,7 @@ import { harvest as harvestInsights, renderDigest as renderInsightDigest } from
|
|
|
14
14
|
import { appendOutcome } from './lib/outcome-buffer.js';
|
|
15
15
|
import { validateForLog, eloOutcomeForStrictComplete } from './lib/advisory-autofeed.js';
|
|
16
16
|
import { readSoul, appendSoulMemory } from './lib/soul.js';
|
|
17
|
+
import { createPublisher as createGdocPublisher } from './lib/gdoc-client.js';
|
|
17
18
|
import {
|
|
18
19
|
start as fdStart,
|
|
19
20
|
status as fdStatus,
|
|
@@ -1353,6 +1354,56 @@ const tools = [
|
|
|
1353
1354
|
additionalProperties: false,
|
|
1354
1355
|
},
|
|
1355
1356
|
},
|
|
1357
|
+
|
|
1358
|
+
// ─── Google Docs publish (service account, headless) ──────────────────
|
|
1359
|
+
// byan-owned, durable publishing : a service-account JWT (no OAuth, no 7-day
|
|
1360
|
+
// expiry) creates a branded Google Doc and returns its URL. Branding via a
|
|
1361
|
+
// template (GDOC_TEMPLATE_ID) or the AcadeNice palette programmatically.
|
|
1362
|
+
{
|
|
1363
|
+
name: 'byan_publish',
|
|
1364
|
+
description:
|
|
1365
|
+
'Publish a branded Google Doc from content via a byan-owned SERVICE ACCOUNT (headless, durable, no OAuth). Copies a branded template when GDOC_TEMPLATE_ID is set (logo+palette in the template), else builds a programmatic doc branded by the AcadeNice palette. Returns the Doc URL; optionally shares it. Needs GOOGLE_APPLICATION_CREDENTIALS (SA key path). Degrades gracefully (ok:false + reason) when unconfigured. NOT remote-safe (network+auth).',
|
|
1366
|
+
inputSchema: {
|
|
1367
|
+
type: 'object',
|
|
1368
|
+
properties: {
|
|
1369
|
+
title: { type: 'string', description: 'Document title (required).' },
|
|
1370
|
+
sections: {
|
|
1371
|
+
type: 'array',
|
|
1372
|
+
description: 'Ordered sections of the document.',
|
|
1373
|
+
items: {
|
|
1374
|
+
type: 'object',
|
|
1375
|
+
properties: {
|
|
1376
|
+
heading: { type: 'string' },
|
|
1377
|
+
body: { type: 'string' },
|
|
1378
|
+
},
|
|
1379
|
+
additionalProperties: false,
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
resources: {
|
|
1383
|
+
type: 'array',
|
|
1384
|
+
description: 'Resource links rendered at the end.',
|
|
1385
|
+
items: {
|
|
1386
|
+
type: 'object',
|
|
1387
|
+
properties: { label: { type: 'string' }, url: { type: 'string' } },
|
|
1388
|
+
additionalProperties: false,
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
fields: {
|
|
1392
|
+
type: 'object',
|
|
1393
|
+
description: 'Extra {{KEY}} placeholder values for template mode.',
|
|
1394
|
+
},
|
|
1395
|
+
templateId: { type: 'string', description: 'Override GDOC_TEMPLATE_ID for this call.' },
|
|
1396
|
+
shareWith: { type: 'string', description: 'Email address to share the Doc with.' },
|
|
1397
|
+
role: {
|
|
1398
|
+
type: 'string',
|
|
1399
|
+
enum: ['reader', 'commenter', 'writer'],
|
|
1400
|
+
description: 'Share role (default reader).',
|
|
1401
|
+
},
|
|
1402
|
+
},
|
|
1403
|
+
required: ['title'],
|
|
1404
|
+
additionalProperties: false,
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1356
1407
|
];
|
|
1357
1408
|
|
|
1358
1409
|
// Remote-safe MVP allowlist: the ONLY tools exposed on the remote Org Connector
|
|
@@ -2092,6 +2143,25 @@ export function createByanServer({ token, remoteOnly = false } = {}) {
|
|
|
2092
2143
|
return { content: [{ type: 'text', text: JSON.stringify(instructions, null, 2) }] };
|
|
2093
2144
|
}
|
|
2094
2145
|
|
|
2146
|
+
// ─── Google Docs publish (service account, headless) ──────────────
|
|
2147
|
+
if (name === 'byan_publish') {
|
|
2148
|
+
const publisher = createGdocPublisher();
|
|
2149
|
+
const result = await publisher.publish(
|
|
2150
|
+
{
|
|
2151
|
+
title: args.title,
|
|
2152
|
+
sections: args.sections,
|
|
2153
|
+
resources: args.resources,
|
|
2154
|
+
fields: args.fields,
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
templateId: args.templateId,
|
|
2158
|
+
shareWith: args.shareWith,
|
|
2159
|
+
role: args.role,
|
|
2160
|
+
}
|
|
2161
|
+
);
|
|
2162
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2095
2165
|
// ─── Leantime tools ───────────────────────────────────────────────
|
|
2096
2166
|
if (name === 'byan_leantime_ping') {
|
|
2097
2167
|
const status = {
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
"name": "byan-hermes-dispatch",
|
|
185
185
|
"module": "core",
|
|
186
186
|
"tier": "connector-bound",
|
|
187
|
-
"sourceHash": "
|
|
187
|
+
"sourceHash": "45003e8a525a792d1a5dbe34ecb8e1443061fa5036a81a38a841598ce5aaf5ff"
|
|
188
188
|
},
|
|
189
189
|
"byan-insight": {
|
|
190
190
|
"name": "byan-insight",
|
|
Binary file
|