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.
Files changed (48) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/install/bin/create-byan-agent-v2.js +44 -1
  3. package/install/lib/claude-native-setup.js +12 -38
  4. package/install/lib/gdoc-setup.js +210 -0
  5. package/install/lib/mcp-extensions/gdrive.js +27 -2
  6. package/install/lib/platforms/claude-code.js +28 -19
  7. package/install/lib/rtk-integration.js +18 -8
  8. package/install/package.json +1 -1
  9. package/install/packages/platform-config/lib/credentials.js +13 -1
  10. package/install/packages/platform-config/lib/mcp-config.js +71 -5
  11. package/install/setup-gdoc.js +41 -0
  12. package/install/setup-rtk.js +1 -1
  13. package/install/templates/.claude/CLAUDE.md +16 -4
  14. package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
  15. package/install/templates/.claude/hooks/inject-soul.js +4 -3
  16. package/install/templates/.claude/hooks/inject-tao.js +65 -25
  17. package/install/templates/.claude/hooks/inject-voice-anchor.js +102 -0
  18. package/install/templates/.claude/hooks/leantime-fd-sync.js +12 -1
  19. package/install/templates/.claude/hooks/lib/delivery-contract.js +143 -0
  20. package/install/templates/.claude/hooks/lib/punt-detect.js +143 -0
  21. package/install/templates/.claude/hooks/punt-guard.js +126 -0
  22. package/install/templates/.claude/hooks/strict-stop-guard.js +29 -6
  23. package/install/templates/.claude/rules/portable-core.md +81 -0
  24. package/install/templates/.claude/settings.json +13 -1
  25. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +16 -2
  26. package/install/templates/_byan/_config/delivery-default.json +22 -0
  27. package/install/templates/_byan/mcp/byan-mcp-server/channel-entry.js +46 -0
  28. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-poll.js +128 -0
  29. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-server.js +234 -0
  30. package/install/templates/_byan/mcp/byan-mcp-server/lib/completeness-evidence.js +159 -0
  31. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-client.js +203 -0
  32. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-content.js +203 -0
  33. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +60 -2
  34. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +4 -1
  35. package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +68 -2
  36. package/install/templates/_byan/mcp/byan-mcp-server/lib/resolve-config.js +15 -2
  37. package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
  38. package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +1 -1
  39. package/install/templates/_byan/mcp/byan-mcp-server/package.json +2 -0
  40. package/install/templates/_byan/mcp/byan-mcp-server/server.js +70 -0
  41. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
  42. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  43. package/install/templates/docs/google-docs-publish.md +121 -0
  44. package/install/templates/docs/leantime-integration.md +11 -1
  45. package/node_modules/byan-platform-config/lib/credentials.js +13 -1
  46. package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
  47. package/package.json +3 -1
  48. 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
- intents.push({ op: 'task_create', backlogId: item.id, headline: item.title || item.id });
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
- return decide(normalizeApi(remote.data));
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
- return decide(normalizedLocal);
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 and the optional Leantime URL stay empty when unset.
30
- const KEYS = ['BYAN_API_URL', 'BYAN_API_TOKEN', 'LEANTIME_API_URL', 'LEANTIME_API_TOKEN'];
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 @.claude/rules/benchmark.md`;
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,8 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@modelcontextprotocol/sdk": "^1.29.0",
17
+ "google-auth-library": "^9.15.1",
18
+ "googleapis": "^148.0.0",
17
19
  "js-yaml": "^4.1.1"
18
20
  },
19
21
  "engines": {
@@ -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": "62b9c959c77468c0e14e3dfd45039151d29b1bcdc18b04b4d0deac606052a976"
187
+ "sourceHash": "45003e8a525a792d1a5dbe34ecb8e1443061fa5036a81a38a841598ce5aaf5ff"
188
188
  },
189
189
  "byan-insight": {
190
190
  "name": "byan-insight",