@smartmemory/compose 0.3.7 → 0.4.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/.claude/skills/compose/SKILL.md +12 -3
- package/.compose-deps.json +51 -25
- package/README.md +79 -7
- package/bin/compose.js +495 -360
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/deps.js +164 -7
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +57 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -23
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +396 -199
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +271 -36
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +109 -23
- package/server/compose-mcp.js +88 -882
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +34 -2
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maya-client.js — COMP-FOH FOH-6 S1
|
|
3
|
+
*
|
|
4
|
+
* HTTP client for Maya's chat surface (`/Users/ruze/reg/my/SmartMemory/maya/`,
|
|
5
|
+
* read-only upstream — POST /api/chat, routes.py:5354; GET /health,
|
|
6
|
+
* maya_server.py:46). Speaks HTTP and nothing else; context composition and
|
|
7
|
+
* write-back belong to the relay.
|
|
8
|
+
*
|
|
9
|
+
* Failure conventions, per design-foh-6.md §2 and the smartmemory-client
|
|
10
|
+
* doctrine:
|
|
11
|
+
* - 401 → ONE retry with the SAME token (transient), then MayaAuthError.
|
|
12
|
+
* Never re-provision here: the identity owns the standing conversation,
|
|
13
|
+
* and a 401 does not prove expiry.
|
|
14
|
+
* - a 2xx we cannot trust (non-JSON body, success !== true, missing
|
|
15
|
+
* message_id) is a failure with kind 'malformed-response', not a success —
|
|
16
|
+
* message_id is the write-back idempotency key, so a reply without one
|
|
17
|
+
* cannot be treated as a completed turn.
|
|
18
|
+
* - network failure / deadline → status 0.
|
|
19
|
+
* - health() never throws.
|
|
20
|
+
*
|
|
21
|
+
* The chat deadline defaults generously (her turn does LLM work upstream);
|
|
22
|
+
* the live VERIFY used 120s.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Non-auth failure of a Maya call. */
|
|
26
|
+
export class MayaHttpError extends Error {
|
|
27
|
+
constructor(message, status, kind) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'MayaHttpError';
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.kind = kind;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 401 after the single same-token retry. The relay maps this to the auth
|
|
36
|
+
* funnel with its explicit continuity-costing actions. */
|
|
37
|
+
export class MayaAuthError extends MayaHttpError {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message, 401);
|
|
40
|
+
this.name = 'MayaAuthError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {string} opts.baseUrl
|
|
47
|
+
* @param {() => (string|Promise<string>)} opts.getToken read at CALL time
|
|
48
|
+
* @param {number} [opts.timeoutMs] health deadline (default 3000)
|
|
49
|
+
* @param {number} [opts.chatTimeoutMs] chat deadline (default 120000)
|
|
50
|
+
*/
|
|
51
|
+
export function createMayaClient({ baseUrl, getToken, timeoutMs = 3000, chatTimeoutMs = 120000 }) {
|
|
52
|
+
async function fetchJson(path, { init, deadline }) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), deadline);
|
|
55
|
+
let res;
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
res = await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
|
|
59
|
+
text = await res.text();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new MayaHttpError(
|
|
62
|
+
`maya: ${path} request failed: ${err?.message ?? 'unknown error'}`, 0,
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
let parsed = null;
|
|
68
|
+
let parseFailed = false;
|
|
69
|
+
try { parsed = JSON.parse(text); } catch { parseFailed = true; }
|
|
70
|
+
return { status: res.status, ok: res.ok, body: parsed, parseFailed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Liveness only. Unauthenticated, never throws. */
|
|
74
|
+
async function health() {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetchJson('/health', { deadline: timeoutMs });
|
|
77
|
+
return { ok: res.ok, status: res.status };
|
|
78
|
+
} catch {
|
|
79
|
+
return { ok: false };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One conversational turn. `channelContext` travels verbatim as the wire's
|
|
85
|
+
* `channel_context: [{author, text}]` and is OMITTED (not sent empty) when
|
|
86
|
+
* absent — an empty array still exercises Maya's context-injection path.
|
|
87
|
+
*
|
|
88
|
+
* @param {{message: string, channelContext?: Array<{author: string, text: string}>,
|
|
89
|
+
* surface?: string, timezone?: string}} args
|
|
90
|
+
* @returns {Promise<{success: true, response: string, message_id: string,
|
|
91
|
+
* memory_available?: boolean}>}
|
|
92
|
+
*/
|
|
93
|
+
async function chat({ message, channelContext, surface, timezone = 'UTC' }) {
|
|
94
|
+
const body = { message, timezone };
|
|
95
|
+
if (Array.isArray(channelContext) && channelContext.length) {
|
|
96
|
+
body.channel_context = channelContext;
|
|
97
|
+
}
|
|
98
|
+
if (surface !== undefined) body.surface = surface;
|
|
99
|
+
|
|
100
|
+
// Captured ONCE per chat() call: "one same-token retry" is a literal
|
|
101
|
+
// contract — reading the token again for the retry could pick up a
|
|
102
|
+
// concurrent paste/re-provision and silently move the retry onto a
|
|
103
|
+
// different identity and conversation (Codex r1 P2).
|
|
104
|
+
const token = await getToken();
|
|
105
|
+
const send = () => fetchJson('/api/chat', {
|
|
106
|
+
deadline: chatTimeoutMs,
|
|
107
|
+
init: {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let res = await send();
|
|
115
|
+
if (res.status === 401) res = await send(); // one same-token retry
|
|
116
|
+
if (res.status === 401) {
|
|
117
|
+
throw new MayaAuthError('maya: authentication rejected after one same-token retry');
|
|
118
|
+
}
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
|
|
121
|
+
}
|
|
122
|
+
if (res.parseFailed) {
|
|
123
|
+
throw new MayaHttpError(
|
|
124
|
+
`maya: chat returned a 2xx (HTTP ${res.status}) with a non-JSON body`,
|
|
125
|
+
res.status, 'malformed-response',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const reply = res.body;
|
|
129
|
+
if (reply?.success !== true || typeof reply?.response !== 'string'
|
|
130
|
+
|| typeof reply?.message_id !== 'string' || !reply.message_id) {
|
|
131
|
+
throw new MayaHttpError(
|
|
132
|
+
'maya: chat returned a 2xx body this relay cannot trust '
|
|
133
|
+
+ '(needs success:true, a response string, and a message_id)',
|
|
134
|
+
res.status, 'malformed-response',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return reply;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Streaming conversational turn. Token chunks are display-only; only a
|
|
141
|
+
* validated final envelope completes the turn. */
|
|
142
|
+
async function chatStream({ message, channelContext, surface, timezone = 'UTC', onToken }) {
|
|
143
|
+
const body = { message, timezone };
|
|
144
|
+
if (Array.isArray(channelContext) && channelContext.length) {
|
|
145
|
+
body.channel_context = channelContext;
|
|
146
|
+
}
|
|
147
|
+
if (surface !== undefined) body.surface = surface;
|
|
148
|
+
|
|
149
|
+
// One deadline and one captured token cover both the HTTP-status retry and
|
|
150
|
+
// the complete response stream. A mid-stream 401 is never retried.
|
|
151
|
+
const token = await getToken();
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(), chatTimeoutMs);
|
|
154
|
+
let streamOpened = false;
|
|
155
|
+
let detached = false;
|
|
156
|
+
|
|
157
|
+
const send = () => fetch(`${baseUrl}/api/chat/stream`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: {
|
|
160
|
+
'Content-Type': 'application/json',
|
|
161
|
+
Accept: 'text/event-stream',
|
|
162
|
+
Authorization: `Bearer ${token}`,
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify(body),
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
let res = await send();
|
|
170
|
+
if (res.status === 401) {
|
|
171
|
+
await res.body?.cancel();
|
|
172
|
+
res = await send();
|
|
173
|
+
}
|
|
174
|
+
if (res.status === 401) {
|
|
175
|
+
throw new MayaAuthError('maya: authentication rejected after one same-token retry');
|
|
176
|
+
}
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
|
|
179
|
+
}
|
|
180
|
+
const contentType = res.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
181
|
+
if (contentType !== 'text/event-stream' || !res.body) {
|
|
182
|
+
throw new MayaHttpError(
|
|
183
|
+
`maya: chat stream returned a 2xx (HTTP ${res.status}) without text/event-stream`,
|
|
184
|
+
res.status, 'malformed-response',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
streamOpened = true;
|
|
188
|
+
|
|
189
|
+
const handleFrame = (frame) => {
|
|
190
|
+
let event = '';
|
|
191
|
+
const data = [];
|
|
192
|
+
for (const line of frame.split('\n')) {
|
|
193
|
+
if (line.startsWith(':')) continue;
|
|
194
|
+
const colon = line.indexOf(':');
|
|
195
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
196
|
+
let value = colon === -1 ? '' : line.slice(colon + 1);
|
|
197
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
198
|
+
if (field === 'event') event = value;
|
|
199
|
+
if (field === 'data') data.push(value);
|
|
200
|
+
}
|
|
201
|
+
if (!['token', 'final', 'error'].includes(event)) return null;
|
|
202
|
+
|
|
203
|
+
let envelope;
|
|
204
|
+
try { envelope = JSON.parse(data.join('\n')); } catch {
|
|
205
|
+
throw new MayaHttpError(
|
|
206
|
+
'maya: chat stream contained a malformed event payload',
|
|
207
|
+
res.status, 'malformed-response',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const payload = envelope?.payload;
|
|
211
|
+
if (event === 'token') {
|
|
212
|
+
if (typeof payload?.text === 'string' && typeof onToken === 'function') {
|
|
213
|
+
onToken(payload.text);
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
if (event === 'error') {
|
|
218
|
+
const detail = typeof payload?.detail === 'string' ? payload.detail : 'maya: stream failed';
|
|
219
|
+
if (payload?.status_code === 401) throw new MayaAuthError(detail);
|
|
220
|
+
throw new MayaHttpError(detail, payload?.status_code);
|
|
221
|
+
}
|
|
222
|
+
if (payload?.success !== true || typeof payload?.response !== 'string'
|
|
223
|
+
|| typeof payload?.message_id !== 'string' || !payload.message_id) {
|
|
224
|
+
throw new MayaHttpError(
|
|
225
|
+
'maya: chat stream final payload cannot be trusted '
|
|
226
|
+
+ '(needs success:true, a response string, and a message_id)',
|
|
227
|
+
res.status, 'malformed-response',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return payload;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const reader = res.body.getReader();
|
|
234
|
+
const decoder = new TextDecoder('utf-8');
|
|
235
|
+
let buffer = '';
|
|
236
|
+
|
|
237
|
+
// Maya persists the turn AFTER emitting `final` (routes.py:5106-5310:
|
|
238
|
+
// conversation history, engagement, storage enqueue) — her own web
|
|
239
|
+
// client drains to EOF. Aborting on receipt of `final` would race that
|
|
240
|
+
// post-final work, so the validated reply returns immediately while a
|
|
241
|
+
// detached drain consumes the stream to EOF. The overall deadline still
|
|
242
|
+
// bounds the socket: on expiry the drain is aborted, never the reply.
|
|
243
|
+
const finishAfterFinal = (reply) => {
|
|
244
|
+
detached = true;
|
|
245
|
+
(async () => {
|
|
246
|
+
try {
|
|
247
|
+
while (!(await reader.read()).done) { /* contents ignored */ }
|
|
248
|
+
} catch { /* deadline abort or teardown — the reply already landed */ }
|
|
249
|
+
finally {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
controller.abort();
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
return reply;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
while (true) {
|
|
258
|
+
const { done, value } = await reader.read();
|
|
259
|
+
if (done) {
|
|
260
|
+
buffer += decoder.decode();
|
|
261
|
+
} else {
|
|
262
|
+
buffer += decoder.decode(value, { stream: true });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let boundary;
|
|
266
|
+
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
|
267
|
+
const frame = buffer.slice(0, boundary);
|
|
268
|
+
buffer = buffer.slice(boundary + 2);
|
|
269
|
+
const reply = handleFrame(frame);
|
|
270
|
+
if (reply) return done ? reply : finishAfterFinal(reply);
|
|
271
|
+
}
|
|
272
|
+
if (done) break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (buffer) {
|
|
276
|
+
const reply = handleFrame(buffer);
|
|
277
|
+
if (reply) return reply;
|
|
278
|
+
}
|
|
279
|
+
throw new MayaHttpError(
|
|
280
|
+
'maya: chat stream ended without a valid final event',
|
|
281
|
+
res.status, 'malformed-response',
|
|
282
|
+
);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const failure = err instanceof MayaHttpError
|
|
285
|
+
? err
|
|
286
|
+
: new MayaHttpError(
|
|
287
|
+
`maya: /api/chat/stream request failed: ${err?.message ?? 'unknown error'}`, 0,
|
|
288
|
+
);
|
|
289
|
+
// The relay uses this to preserve the JSON-before-open / SSE-after-open
|
|
290
|
+
// boundary even when an error event arrives before the first token.
|
|
291
|
+
if (streamOpened) failure.streamStarted = true;
|
|
292
|
+
throw failure;
|
|
293
|
+
} finally {
|
|
294
|
+
if (!detached) {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
controller.abort();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { health, chat, chatStream };
|
|
302
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maya-config.js — COMP-FOH FOH-6 S1
|
|
3
|
+
*
|
|
4
|
+
* Config readers for the Maya colleague coupling. Leaf module (no fetch, no
|
|
5
|
+
* state writes), mirroring lib/smartmemory-config.js: uncached direct reads of
|
|
6
|
+
* `.compose/compose.json`, try/catch → absent on missing/malformed.
|
|
7
|
+
*
|
|
8
|
+
* The feature switch is the PRESENCE of the `maya` block (design-foh-6.md
|
|
9
|
+
* §Config): absent means "not installed" (no chrome button), which is a
|
|
10
|
+
* different state from "installed but degraded" (a funnel).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
function readComposeJson(cwd) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `maya` block, or null when the feature is not installed.
|
|
26
|
+
* @param {string} cwd
|
|
27
|
+
* @returns {{ baseUrl?: string, auth?: { mode?: 'provision'|'static' } } | null}
|
|
28
|
+
*/
|
|
29
|
+
export function getMayaConfig(cwd) {
|
|
30
|
+
const block = readComposeJson(cwd).maya;
|
|
31
|
+
return block && typeof block === 'object' ? block : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The fluid SmartMemory workspace id — the value the colleague identity's
|
|
36
|
+
* workspace claim must NOT equal (shallow-binding isolation, design §2).
|
|
37
|
+
* @param {string} cwd
|
|
38
|
+
* @returns {string | null}
|
|
39
|
+
*/
|
|
40
|
+
export function getFluidWorkspaceId(cwd) {
|
|
41
|
+
const ws = readComposeJson(cwd).fluid?.smartmemory?.workspaceId;
|
|
42
|
+
return typeof ws === 'string' && ws ? ws : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether the fluid provider is SmartMemory-backed. The colleague never runs
|
|
47
|
+
* degraded (COLLEAGUE-ALL-IN): on the local floor the panel is a
|
|
48
|
+
* connect-SmartMemory funnel, not a plain chat.
|
|
49
|
+
* @param {string} cwd
|
|
50
|
+
*/
|
|
51
|
+
export function hasSmartmemoryFluidProvider(cwd) {
|
|
52
|
+
return readComposeJson(cwd).fluid?.provider === 'smartmemory';
|
|
53
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maya-identity.js — COMP-FOH FOH-6 S1
|
|
3
|
+
*
|
|
4
|
+
* The colleague identity store: `data/maya-identity.json` (covered by
|
|
5
|
+
* `.gitignore`'s blanket `data/` entry). One identity per project; it OWNS
|
|
6
|
+
* Maya's single standing conversation (session per JWT identity,
|
|
7
|
+
* session_registry.py:486-527), so nothing here re-provisions implicitly —
|
|
8
|
+
* minting a fresh identity destroys the thread AND the colleague-workspace
|
|
9
|
+
* memory, and is only ever an explicit user action (design §2).
|
|
10
|
+
*
|
|
11
|
+
* Provisioning uses smart-memory-service's sanctioned test surface, the same
|
|
12
|
+
* path the FOH-4/5/6 live-fires used:
|
|
13
|
+
* POST /test/provision-user {email} → {user_id, tenant_id, team_id, access_token}
|
|
14
|
+
* (409 on a repeated email — probed live 2026-08-11; there is no
|
|
15
|
+
* token-refresh path on the test surface yet, upstream ask filed with the
|
|
16
|
+
* channel_context issue)
|
|
17
|
+
* POST /memory/beta/nda/accept {"version":"v1"} (Bearer-authenticated)
|
|
18
|
+
* DELETE /test/provision-user {email, user_id, tenant_id}
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, chmodSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
/** Provisioning/NDA failure. `kind` mirrors the relay's funnel taxonomy. */
|
|
26
|
+
export class MayaIdentityError extends Error {
|
|
27
|
+
constructor(message, kind = 'auth') {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'MayaIdentityError';
|
|
30
|
+
this.kind = kind;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The colleague token's workspace claim equals the fluid workspace —
|
|
35
|
+
* accepting it would silently enable the deferred deep-binding behaviour
|
|
36
|
+
* (Maya's turn-ingestion writing into the fluid workspace). */
|
|
37
|
+
export class MayaWorkspaceCollisionError extends Error {
|
|
38
|
+
constructor(claim) {
|
|
39
|
+
super(
|
|
40
|
+
`maya: the colleague token's workspace claim (${claim}) IS the fluid workspace — `
|
|
41
|
+
+ 'refusing. Shallow binding requires a dedicated colleague workspace; '
|
|
42
|
+
+ 'provision a separate identity or paste a token scoped elsewhere.',
|
|
43
|
+
);
|
|
44
|
+
this.name = 'MayaWorkspaceCollisionError';
|
|
45
|
+
this.claim = claim;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function identityPath(projectRoot) {
|
|
50
|
+
return join(projectRoot, 'data', 'maya-identity.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns {object|null} the stored identity, or null when none exists */
|
|
54
|
+
export function loadIdentity(projectRoot) {
|
|
55
|
+
const path = identityPath(projectRoot);
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
58
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
59
|
+
// Enforce owner-only permissions on files created before the 0600 write
|
|
60
|
+
// path existed (Codex r1 P2). Best-effort — a read-only checkout must not
|
|
61
|
+
// break loading.
|
|
62
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
63
|
+
return parsed;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Owner-only (0600) and ATOMIC: the file holds a bearer credential, so it must
|
|
71
|
+
* never be group/world-readable, and a crash mid-write must never truncate it
|
|
72
|
+
* — a truncated file loads as null and the next turn would silently provision
|
|
73
|
+
* a fresh identity, destroying the standing conversation (Codex r1 P2).
|
|
74
|
+
*/
|
|
75
|
+
export function saveIdentity(projectRoot, identity) {
|
|
76
|
+
mkdirSync(join(projectRoot, 'data'), { recursive: true });
|
|
77
|
+
const path = identityPath(projectRoot);
|
|
78
|
+
const tmp = `${path}.tmp.${randomBytes(6).toString('hex')}`;
|
|
79
|
+
try {
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
|
|
81
|
+
renameSync(tmp, path);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
rmSync(tmp, { force: true });
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function clearIdentity(projectRoot) {
|
|
89
|
+
rmSync(identityPath(projectRoot), { force: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function requestJson(url, { body, token, timeoutMs = 30000, fetchFn = fetch, method = 'POST' }) {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetchFn(url, {
|
|
97
|
+
method,
|
|
98
|
+
headers: {
|
|
99
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
100
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
101
|
+
},
|
|
102
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
});
|
|
105
|
+
const text = await res.text();
|
|
106
|
+
let parsed = null;
|
|
107
|
+
try { parsed = JSON.parse(text); } catch { /* tolerated; shape-checked below */ }
|
|
108
|
+
return { status: res.status, body: parsed };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new MayaIdentityError(`maya: ${method} ${url} failed: ${err?.message ?? 'unknown'}`);
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mint a fresh colleague identity. A RANDOM email per call — the provision
|
|
118
|
+
* surface refuses repeats (409), and a colleague identity is never shared
|
|
119
|
+
* across projects (documented-unsupported, design §2).
|
|
120
|
+
*
|
|
121
|
+
* Does NOT save: the caller decides persistence (ensureIdentity does both).
|
|
122
|
+
*/
|
|
123
|
+
export async function provisionIdentity({ smBaseUrl, fetchFn = fetch }) {
|
|
124
|
+
const email = `maya-colleague-${randomBytes(6).toString('hex')}@compose.invalid`;
|
|
125
|
+
const res = await requestJson(`${smBaseUrl}/test/provision-user`, { body: { email }, fetchFn });
|
|
126
|
+
if (res.status !== 200 || typeof res.body?.access_token !== 'string') {
|
|
127
|
+
throw new MayaIdentityError(
|
|
128
|
+
`maya: provisioning failed (HTTP ${res.status})`
|
|
129
|
+
+ (res.body?.detail ? ` — ${String(res.body.detail).slice(0, 200)}` : ''),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const { user_id, tenant_id, team_id, access_token } = res.body;
|
|
133
|
+
return {
|
|
134
|
+
mode: 'provision', email, user_id, tenant_id, team_id, access_token,
|
|
135
|
+
ndaAccepted: false, provisionedAt: new Date().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Accept the beta NDA for a provisioned identity (403 `nda_required` gates
|
|
140
|
+
* every memory route until this runs — FOH-4 ledger prereq 3). */
|
|
141
|
+
export async function acceptNda({ smBaseUrl, token, fetchFn = fetch }) {
|
|
142
|
+
const res = await requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
|
|
143
|
+
body: { version: 'v1' }, token, fetchFn,
|
|
144
|
+
});
|
|
145
|
+
if (res.status < 200 || res.status >= 300) {
|
|
146
|
+
throw new MayaIdentityError(`maya: NDA accept failed (HTTP ${res.status})`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tear down a provisioned identity (explicit user action only). */
|
|
151
|
+
export async function teardownIdentity(projectRoot, { smBaseUrl, fetchFn = fetch }) {
|
|
152
|
+
const identity = loadIdentity(projectRoot);
|
|
153
|
+
if (!identity) return { removed: false };
|
|
154
|
+
if (identity.mode === 'provision' && identity.email) {
|
|
155
|
+
await requestJson(`${smBaseUrl}/test/provision-user`, {
|
|
156
|
+
method: 'DELETE', fetchFn,
|
|
157
|
+
body: { email: identity.email, user_id: identity.user_id, tenant_id: identity.tenant_id },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
clearIdentity(projectRoot);
|
|
161
|
+
return { removed: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The identity's workspace claim, or null when none is derivable. Provisioned
|
|
166
|
+
* identities carry `team_id` from the provision response (VERIFY-1: the JWT
|
|
167
|
+
* payload itself carries NO workspace claims — Maya resolves the workspace
|
|
168
|
+
* server-side from the verified user record). Static tokens fall back to
|
|
169
|
+
* decoding the JWT body for the claims Maya's `_extract_team_id` reads.
|
|
170
|
+
*/
|
|
171
|
+
export function workspaceClaimOf(identity) {
|
|
172
|
+
if (!identity) return null;
|
|
173
|
+
if (typeof identity.team_id === 'string' && identity.team_id) return identity.team_id;
|
|
174
|
+
const token = identity.access_token;
|
|
175
|
+
if (typeof token !== 'string') return null;
|
|
176
|
+
const parts = token.split('.');
|
|
177
|
+
if (parts.length < 2) return null;
|
|
178
|
+
try {
|
|
179
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
180
|
+
return payload.workspace_id ?? payload.current_workspace_id
|
|
181
|
+
?? payload.default_team_id ?? payload.team_id ?? null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The shallow-binding invariant (design §2, VERIFY-3): refuse a colleague
|
|
189
|
+
* token whose workspace claim equals the fluid workspace. An underivable claim
|
|
190
|
+
* is ALLOWED here for STORED identities — provisioned identities always carry
|
|
191
|
+
* `team_id`, and static identities have their claim resolved and stored at
|
|
192
|
+
* paste time by `fetchVerifiedWorkspace` (which fails CLOSED — see below), so
|
|
193
|
+
* an underivable claim on a stored identity means only that this project has
|
|
194
|
+
* no fluid workspace to collide with. Cross-project token reuse remains
|
|
195
|
+
* documented-unsupported, not detected.
|
|
196
|
+
*/
|
|
197
|
+
export function validateWorkspaceIsolation(identity, fluidWorkspaceId) {
|
|
198
|
+
if (!fluidWorkspaceId) return;
|
|
199
|
+
const claim = workspaceClaimOf(identity);
|
|
200
|
+
if (claim && claim === fluidWorkspaceId) throw new MayaWorkspaceCollisionError(claim);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve a token's VERIFIED workspace from smart-memory-service itself
|
|
205
|
+
* (`GET /auth/me` → `default_team_id` — the same user-record field Maya's
|
|
206
|
+
* `_extract_team_id` falls back to). Exists because the JWT-decode fallback is
|
|
207
|
+
* vacuous in practice: real tokens carry NO workspace claims (VERIFY-1), so a
|
|
208
|
+
* pasted static token could silently defeat the isolation check (Codex r1 P1).
|
|
209
|
+
*
|
|
210
|
+
* FAILS CLOSED: an unreachable service, a rejected token, or a user record
|
|
211
|
+
* without a resolvable team all throw — the paste flow refuses to store a
|
|
212
|
+
* token whose workspace it cannot verify.
|
|
213
|
+
*/
|
|
214
|
+
export async function fetchVerifiedWorkspace({ smBaseUrl, token, fetchFn = fetch }) {
|
|
215
|
+
if (!smBaseUrl) {
|
|
216
|
+
throw new MayaIdentityError('maya: cannot verify the token — smartmemory baseUrl is not configured');
|
|
217
|
+
}
|
|
218
|
+
const res = await requestJson(`${smBaseUrl}/auth/me`, { method: 'GET', token, fetchFn });
|
|
219
|
+
if (res.status !== 200 || !res.body || typeof res.body !== 'object') {
|
|
220
|
+
throw new MayaIdentityError(
|
|
221
|
+
`maya: could not verify the token against SmartMemory (HTTP ${res.status}) — not storing it`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const team = res.body.default_team_id;
|
|
225
|
+
if (typeof team !== 'string' || !team) {
|
|
226
|
+
throw new MayaIdentityError(
|
|
227
|
+
'maya: the token verified but resolves no workspace (no default_team_id) — '
|
|
228
|
+
+ 'refusing to store a token whose isolation cannot be checked',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return team;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Per-project serialization for ensureIdentity. Two concurrent first turns
|
|
236
|
+
* that both observe "no identity" would otherwise provision two competing
|
|
237
|
+
* users, overwrite each other's files, and fork the standing conversation
|
|
238
|
+
* (Codex r1 P2). The server is a single process, so an in-process promise
|
|
239
|
+
* chain is the whole fix: every caller re-loads at the head of its own turn
|
|
240
|
+
* in the chain and sees the winner's identity.
|
|
241
|
+
*/
|
|
242
|
+
const _ensureChain = new Map(); // projectRoot -> tail Promise
|
|
243
|
+
|
|
244
|
+
async function ensureIdentityUnserialized(projectRoot, { smBaseUrl, mode, fetchFn = fetch }) {
|
|
245
|
+
let identity = loadIdentity(projectRoot);
|
|
246
|
+
if (!identity) {
|
|
247
|
+
if (mode !== 'provision') {
|
|
248
|
+
throw new MayaIdentityError('maya: no token configured — paste one in the panel', 'auth');
|
|
249
|
+
}
|
|
250
|
+
if (!smBaseUrl) {
|
|
251
|
+
throw new MayaIdentityError('maya: provisioning needs the smartmemory baseUrl', 'auth');
|
|
252
|
+
}
|
|
253
|
+
identity = await provisionIdentity({ smBaseUrl, fetchFn });
|
|
254
|
+
saveIdentity(projectRoot, identity);
|
|
255
|
+
}
|
|
256
|
+
// A saved-but-unaccepted NDA (crash between provision and accept) is retried
|
|
257
|
+
// on the SAME identity rather than minting a new one.
|
|
258
|
+
if (identity.mode === 'provision' && !identity.ndaAccepted) {
|
|
259
|
+
await acceptNda({ smBaseUrl, token: identity.access_token, fetchFn });
|
|
260
|
+
identity.ndaAccepted = true;
|
|
261
|
+
saveIdentity(projectRoot, identity);
|
|
262
|
+
}
|
|
263
|
+
return identity;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The relay's per-turn identity resolution: load, lazily provision on FIRST
|
|
268
|
+
* use (provision mode only — static mode with no pasted token is an auth
|
|
269
|
+
* funnel), and complete a pending NDA. Never called on a 401 — that funnel is
|
|
270
|
+
* explicit-action-only. Serialized per project (see `_ensureChain`).
|
|
271
|
+
*/
|
|
272
|
+
export async function ensureIdentity(projectRoot, opts) {
|
|
273
|
+
const tail = _ensureChain.get(projectRoot) ?? Promise.resolve();
|
|
274
|
+
const run = tail
|
|
275
|
+
.catch(() => {}) // a predecessor's failure must not poison the chain
|
|
276
|
+
.then(() => ensureIdentityUnserialized(projectRoot, opts));
|
|
277
|
+
_ensureChain.set(projectRoot, run);
|
|
278
|
+
try {
|
|
279
|
+
return await run;
|
|
280
|
+
} finally {
|
|
281
|
+
if (_ensureChain.get(projectRoot) === run) _ensureChain.delete(projectRoot);
|
|
282
|
+
}
|
|
283
|
+
}
|
package/lib/migrate-anon.js
CHANGED
|
@@ -181,6 +181,11 @@ export async function promoteAnonRow(cwd, row, { code, status = 'PLANNED', scaff
|
|
|
181
181
|
status,
|
|
182
182
|
complexity: 'S',
|
|
183
183
|
position,
|
|
184
|
+
// COMP-COMPLETION-GATE: promoting an anonymous ROADMAP row transcribes a
|
|
185
|
+
// status that already exists in the roadmap — including COMPLETE for work
|
|
186
|
+
// finished long before the gate. That is history, not a new completion, so
|
|
187
|
+
// it takes the named migration exemption rather than the completion path.
|
|
188
|
+
_migration: { reason: `promoting anonymous ROADMAP row in phase ${row.phaseId}` },
|
|
184
189
|
});
|
|
185
190
|
} catch (err) {
|
|
186
191
|
if (err && err.code === 'ROADMAP_PARTIAL_WRITE') throw err;
|
package/lib/migrate-roadmap.js
CHANGED
|
@@ -81,6 +81,21 @@ export function migrateRoadmap(cwd, opts = {}) {
|
|
|
81
81
|
feature.created = new Date().toISOString().slice(0, 10);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// COMP-COMPLETION-GATE slice 3 (AC-17, path 13): the migration legitimately
|
|
85
|
+
// copies historical COMPLETE rows into feature.json — it transcribes a
|
|
86
|
+
// completion that already happened, it does not mint one. The exemption is
|
|
87
|
+
// NAMED and LOGGED per write and covers BOTH branches: an existing file
|
|
88
|
+
// (`--overwrite`) and a missing one (created without it — §2.7 round 2
|
|
89
|
+
// finding 7: an exemption keyed on --overwrite alone would leave the
|
|
90
|
+
// create path as an unlogged bypass). `writeFeature` is the raw file
|
|
91
|
+
// primitive; this callsite is on the completion-write allowlist.
|
|
92
|
+
if (feature.status === 'COMPLETE') {
|
|
93
|
+
console.warn(
|
|
94
|
+
`[migrate-roadmap] migration exemption (${existing ? 'overwrite' : 'create'}): writing ` +
|
|
95
|
+
`${entry.code} as COMPLETE from its ROADMAP row without a completion record — ` +
|
|
96
|
+
`ROADMAP is the source of truth during migration.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
84
99
|
if (opts.dryRun) {
|
|
85
100
|
console.log(`${existing ? 'update' : 'create'}: ${featuresDir}/${entry.code}/feature.json`);
|
|
86
101
|
} else {
|