@n8r/surface 0.0.0-stage → 0.1.0-experimental.2

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 (96) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-DOCS +395 -0
  3. package/LICENSING.md +36 -0
  4. package/README.md +181 -2
  5. package/dist/activity.d.ts +16 -0
  6. package/dist/activity.js +44 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +260 -0
  9. package/dist/codec.d.ts +4 -0
  10. package/dist/codec.js +41 -0
  11. package/dist/codex.d.ts +72 -0
  12. package/dist/codex.js +510 -0
  13. package/dist/contracts.d.ts +129 -0
  14. package/dist/contracts.js +1 -0
  15. package/dist/html.d.ts +5 -0
  16. package/dist/html.js +50 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.js +5 -0
  19. package/dist/notes.d.ts +52 -0
  20. package/dist/notes.js +184 -0
  21. package/dist/portable.d.ts +10 -0
  22. package/dist/portable.js +71 -0
  23. package/dist/revisions.d.ts +2 -0
  24. package/dist/revisions.js +18 -0
  25. package/dist/settings.d.ts +23 -0
  26. package/dist/settings.js +100 -0
  27. package/dist/setup.d.ts +9 -0
  28. package/dist/setup.js +161 -0
  29. package/dist/source-tools.d.ts +102 -0
  30. package/dist/source-tools.js +201 -0
  31. package/dist/startup.d.ts +7 -0
  32. package/dist/startup.js +74 -0
  33. package/dist/surface-draft.d.ts +19 -0
  34. package/dist/surface-draft.js +95 -0
  35. package/dist/surface-instructions.d.ts +2 -0
  36. package/dist/surface-instructions.js +23 -0
  37. package/dist/surface-store.d.ts +36 -0
  38. package/dist/surface-store.js +181 -0
  39. package/dist/validate.d.ts +4 -0
  40. package/dist/validate.js +184 -0
  41. package/docs/n8r-surface/README.md +133 -0
  42. package/docs/n8r-surface/codex-research-transcript.md +51 -0
  43. package/docs/n8r-surface/codex-research-verification.md +176 -0
  44. package/docs/n8r-surface/command-outcomes.md +564 -0
  45. package/docs/n8r-surface/contracts.md +210 -0
  46. package/docs/n8r-surface/critical-path.md +163 -0
  47. package/docs/n8r-surface/design.md +289 -0
  48. package/docs/n8r-surface/getting-started.md +107 -0
  49. package/docs/n8r-surface/host-contract.md +176 -0
  50. package/docs/n8r-surface/implementation-decisions.md +96 -0
  51. package/docs/n8r-surface/npm-distribution.md +137 -0
  52. package/docs/n8r-surface/npm-release.md +188 -0
  53. package/docs/n8r-surface/recreation-handoff.md +117 -0
  54. package/docs/n8r-surface/researcher-discovery.md +157 -0
  55. package/docs/n8r-surface/researcher-prepared-check.md +117 -0
  56. package/docs/n8r-surface/researcher-reconciliation-trial.md +149 -0
  57. package/docs/n8r-surface/researcher-repository-check.md +95 -0
  58. package/docs/n8r-surface/researcher-skeleton-check.md +84 -0
  59. package/docs/n8r-surface/researcher-skill-verification.md +725 -0
  60. package/docs/n8r-surface/researcher-skill.md +255 -0
  61. package/docs/n8r-surface/researcher-vault.md +134 -0
  62. package/docs/n8r-surface/save-reopen-transcript.md +211 -0
  63. package/docs/n8r-surface/save-reopen-verification.md +152 -0
  64. package/docs/n8r-surface/startup-human-check.md +172 -0
  65. package/docs/n8r-surface/startup-verification.md +161 -0
  66. package/docs/n8r-surface/startup.md +151 -0
  67. package/docs/n8r-surface/surface-revisions-transcript.md +552 -0
  68. package/docs/n8r-surface/surface-revisions-verification.md +161 -0
  69. package/docs/n8r-surface/vault-setup-verification.md +210 -0
  70. package/docs/n8r-surface/verification.md +117 -0
  71. package/docs/n8r-surface/versioning.md +183 -0
  72. package/examples/researcher/README.md +92 -0
  73. package/examples/researcher/composed.surface.json +430 -0
  74. package/examples/researcher/composed.surface.yaml +283 -0
  75. package/examples/researcher/harbor-r1.research.html +193 -0
  76. package/examples/researcher/harbor-r2.research.html +226 -0
  77. package/examples/researcher/lumen-manual-edit.pending.html +271 -0
  78. package/examples/researcher/lumen-r1.research.html +271 -0
  79. package/examples/researcher/lumen-r2.research.html +280 -0
  80. package/examples/researcher/sources/harbor-api.md +16 -0
  81. package/examples/researcher/sources/harbor-auth.md +8 -0
  82. package/examples/researcher/sources/lumen-guide.md +28 -0
  83. package/examples/researcher/sources/lumen-settings.md +9 -0
  84. package/package.json +45 -4
  85. package/plugins/n8r-surface/.codex-plugin/plugin.json +31 -0
  86. package/plugins/n8r-surface/LICENSE +202 -0
  87. package/plugins/n8r-surface/LICENSE-DOCS +395 -0
  88. package/plugins/n8r-surface/LICENSING.md +36 -0
  89. package/plugins/n8r-surface/assets/icon.svg +5 -0
  90. package/plugins/n8r-surface/plugin.json +27 -0
  91. package/plugins/n8r-surface/skills/research/SKILL.md +122 -0
  92. package/plugins/n8r-surface/skills/research/agents/openai.yaml +4 -0
  93. package/plugins/n8r-surface/skills/research/references/discovery-map.md +160 -0
  94. package/plugins/n8r-surface/skills/research/references/model-meanings.md +55 -0
  95. package/plugins/n8r-surface/skills/research/references/skeleton-surface.md +105 -0
  96. package/plugins/n8r-surface/skills/research/references/working-notes.md +116 -0
@@ -0,0 +1,2 @@
1
+ /** Content fields mirror the existing experimental contracts; host metadata is omitted. */
2
+ export declare const surfaceInstructions = "\nSTRUCTURED SURFACE CAPABILITY (host adapter 0.1):\nWhen the user asks to create or update a Surface, call surface_draft with research and surface content objects. This stages a preview only. Never claim a save. Natural-language save requests explain /save surface. /save still saves Markdown. A later user turn invalidates the preview, so restage a revised pair after answering a correction. Retain stable component IDs from notes. If an old label is not namespaced, record the explicit old-to-new mapping in research.notes and show it to the human. Do not silently rename identities.\n\nUse these exact content fields (no contract, id, revision or records; the host assigns them):\nresearch: {title,scope,extensions,sources,findings,alternatives,decisions,questions,notes}.\nsurface: {title,scope,extensions,kind:\"definition\",vocabularies,nodes,relationships,requirements,coverage,gaps}.\nAll collections are arrays except extensions and entity attributes, which are objects. Empty arrays/objects are allowed. Nonempty text is required for text fields. IDs use a URI-style namespace such as urn:lumen:app. All timestamps use real UTC seconds, YYYY-MM-DDTHH:mm:ssZ, without fractions. Only safe integer JSON numbers are supported. Do not add undocumented fields.\n\nSource: {id,location,version,digest,inspectedAt,coverage:[{path,status,note}]}. location is a portable relative source path from an ACTUAL source_read/search or saved host evidence, never an absolute path. digest must be the exact SHA-256 from that tool or saved host evidence. Never invent a digest. status is inspected|missing|unreadable|excluded|truncated|unsupported. Record ranges and limits. On revision retain historical source objects exactly and add a new source ID for new bytes or inspection; never relabel old evidence as fresh.\nFinding: {id,subject,property,state,note,claims:[{value,evidence:[{source,locator}]}]}. subject is a component ID; evidence.source references sources[].id. state is known|unknown|omitted|redacted|absent|out-of-scope|conflicting. known/absent require one agreed sourced value; conflicting requires different sourced values; other states require empty claims. Every claim requires evidence. Keep human decisions separate from sourced claims.\nAlternative: {id,title,findings:[finding IDs],requirements:[text descriptions]}.\nDecision: {id,alternative,choice:\"selected\"|\"rejected\"|\"deferred\",reason,by,at}. One current decision per alternative. Use actual human choices, never infer selection from support. Preserve prior choices unless the human changes them. Record an explicit correction's reason and date; previous revisions retain prior choices.\nQuestion: {id,text,findings:[finding IDs]}. Note: {by,at,text}. Keep human prose, unresolved questions and recommended next investigations.\nVocabulary: {id,version}; version is a token such as 0.1. Use an explicitly scoped provisional vocabulary, not a claim of universal compatibility.\nNode: {id,label,type:{vocabulary,term},attributes:{property:{state,note,value?}},basis:[{items:[finding/decision/alternative/question IDs]}],requirements:[requirement IDs]}.\nRelationship: same fields as a node EXCEPT no label, plus {from:node ID,to:node ID,claim:\"declared\"}. Keep endpoint identity for retained relationship IDs. Only selected composition belongs in relationships. A supported option is not automatically selected. Shared components, cycles and parallel edges are allowed.\nFact state is known|unknown|omitted|redacted|absent|out-of-scope. Only known facts have a value; all other states omit value. Put conflicting values in research and unknown in the Surface with an explanatory note.\nRequirement: {id,kind:\"secret\"|\"resource\"|\"configuration\",description,state}. Never include credential values. coverage and gaps are arrays of nonempty strings. Surface basis contains only items; the host fills the exact research record/revision and digest.\n\nOn pair reopening keep research and Surface identities via host bookkeeping. Retain all research sources, findings, alternatives, decisions, questions and component IDs. Keep historical claims and research notes exactly; append corrections and new evidence instead of removing history. Every node and relationship needs nonempty basis linking structured findings/decisions. Retained finding subjects/properties and node types cannot change; retain opaque extensions exactly. Removed selections remain rejected/deferred in research. Preserve prior unknowns unless evidence resolves them. Explain source unavailable/changed status and distinguish declared from reachable or exercised. On validation error, repair the staged content using feedback; do not discard evidence to make validation pass.\n";
@@ -0,0 +1,23 @@
1
+ /** Content fields mirror the existing experimental contracts; host metadata is omitted. */
2
+ export const surfaceInstructions = `
3
+ STRUCTURED SURFACE CAPABILITY (host adapter 0.1):
4
+ When the user asks to create or update a Surface, call surface_draft with research and surface content objects. This stages a preview only. Never claim a save. Natural-language save requests explain /save surface. /save still saves Markdown. A later user turn invalidates the preview, so restage a revised pair after answering a correction. Retain stable component IDs from notes. If an old label is not namespaced, record the explicit old-to-new mapping in research.notes and show it to the human. Do not silently rename identities.
5
+
6
+ Use these exact content fields (no contract, id, revision or records; the host assigns them):
7
+ research: {title,scope,extensions,sources,findings,alternatives,decisions,questions,notes}.
8
+ surface: {title,scope,extensions,kind:"definition",vocabularies,nodes,relationships,requirements,coverage,gaps}.
9
+ All collections are arrays except extensions and entity attributes, which are objects. Empty arrays/objects are allowed. Nonempty text is required for text fields. IDs use a URI-style namespace such as urn:lumen:app. All timestamps use real UTC seconds, YYYY-MM-DDTHH:mm:ssZ, without fractions. Only safe integer JSON numbers are supported. Do not add undocumented fields.
10
+
11
+ Source: {id,location,version,digest,inspectedAt,coverage:[{path,status,note}]}. location is a portable relative source path from an ACTUAL source_read/search or saved host evidence, never an absolute path. digest must be the exact SHA-256 from that tool or saved host evidence. Never invent a digest. status is inspected|missing|unreadable|excluded|truncated|unsupported. Record ranges and limits. On revision retain historical source objects exactly and add a new source ID for new bytes or inspection; never relabel old evidence as fresh.
12
+ Finding: {id,subject,property,state,note,claims:[{value,evidence:[{source,locator}]}]}. subject is a component ID; evidence.source references sources[].id. state is known|unknown|omitted|redacted|absent|out-of-scope|conflicting. known/absent require one agreed sourced value; conflicting requires different sourced values; other states require empty claims. Every claim requires evidence. Keep human decisions separate from sourced claims.
13
+ Alternative: {id,title,findings:[finding IDs],requirements:[text descriptions]}.
14
+ Decision: {id,alternative,choice:"selected"|"rejected"|"deferred",reason,by,at}. One current decision per alternative. Use actual human choices, never infer selection from support. Preserve prior choices unless the human changes them. Record an explicit correction's reason and date; previous revisions retain prior choices.
15
+ Question: {id,text,findings:[finding IDs]}. Note: {by,at,text}. Keep human prose, unresolved questions and recommended next investigations.
16
+ Vocabulary: {id,version}; version is a token such as 0.1. Use an explicitly scoped provisional vocabulary, not a claim of universal compatibility.
17
+ Node: {id,label,type:{vocabulary,term},attributes:{property:{state,note,value?}},basis:[{items:[finding/decision/alternative/question IDs]}],requirements:[requirement IDs]}.
18
+ Relationship: same fields as a node EXCEPT no label, plus {from:node ID,to:node ID,claim:"declared"}. Keep endpoint identity for retained relationship IDs. Only selected composition belongs in relationships. A supported option is not automatically selected. Shared components, cycles and parallel edges are allowed.
19
+ Fact state is known|unknown|omitted|redacted|absent|out-of-scope. Only known facts have a value; all other states omit value. Put conflicting values in research and unknown in the Surface with an explanatory note.
20
+ Requirement: {id,kind:"secret"|"resource"|"configuration",description,state}. Never include credential values. coverage and gaps are arrays of nonempty strings. Surface basis contains only items; the host fills the exact research record/revision and digest.
21
+
22
+ On pair reopening keep research and Surface identities via host bookkeeping. Retain all research sources, findings, alternatives, decisions, questions and component IDs. Keep historical claims and research notes exactly; append corrections and new evidence instead of removing history. Every node and relationship needs nonempty basis linking structured findings/decisions. Retained finding subjects/properties and node types cannot change; retain opaque extensions exactly. Removed selections remain rejected/deferred in research. Preserve prior unknowns unless evidence resolves them. Explain source unavailable/changed status and distinguish declared from reachable or exercised. On validation error, repair the staged content using feedback; do not discard evidence to make validation pass.
23
+ `;
@@ -0,0 +1,36 @@
1
+ import { type SurfaceDraft } from './surface-draft.js';
2
+ import { type Note, type NoteContext } from './notes.js';
3
+ type Manifest = {
4
+ version: 1;
5
+ files: {
6
+ 'research.html': string;
7
+ 'surface.json': string;
8
+ };
9
+ provenance: NoteContext;
10
+ note?: {
11
+ file: string;
12
+ digest: string;
13
+ };
14
+ predecessor?: {
15
+ directory: string;
16
+ manifestDigest: string;
17
+ };
18
+ };
19
+ export type SavedSurfacePair = SurfaceDraft & {
20
+ directory: string;
21
+ researchPath: string;
22
+ surfacePath: string;
23
+ manifest: Manifest;
24
+ bytes: Record<string, Buffer>;
25
+ };
26
+ export declare function readSurfacePair(path: string): SavedSurfacePair;
27
+ export declare class SurfaceStore {
28
+ private note?;
29
+ readonly directory: string;
30
+ previous: SavedSurfacePair | undefined;
31
+ private stamps;
32
+ constructor(vault: string, source: string, previous?: SavedSurfacePair, note?: Note | undefined);
33
+ private unchanged;
34
+ save(draft: SurfaceDraft, provenance: NoteContext): SavedSurfacePair;
35
+ }
36
+ export {};
@@ -0,0 +1,181 @@
1
+ import fs from 'node:fs';
2
+ import { basename, dirname, join, resolve, relative, sep } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { decode, encode } from './codec.js';
5
+ import { readResearchHtml, renderResearchHtml } from './html.js';
6
+ import { verifyReferences } from './validate.js';
7
+ import { canonical } from './portable.js';
8
+ import { verifyPairSuccessor } from './surface-draft.js';
9
+ import {} from './notes.js';
10
+ import { InspectionError, readBytes } from './settings.js';
11
+ const LIMIT = 8 * 1024 * 1024;
12
+ const HASH = /^sha256:[a-f0-9]{64}$/;
13
+ const MEMBERS = ['research.html', 'surface.json', 'manifest.json'];
14
+ const hash = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
15
+ function fail(message) { throw new InspectionError(message); }
16
+ function safeName(value) { return typeof value === 'string' && !!value && value !== '.' && value !== '..' && !/[\\/\x00-\x1f]/.test(value); }
17
+ function checkProvenance(p) {
18
+ if (!p || typeof p.model !== 'string' || typeof p.researcher?.version !== 'string' || !HASH.test(p.researcher?.digest)
19
+ || !Array.isArray(p.evidence) || p.evidence.length > 1000 || p.evidence.some((e) => !e || typeof e.path !== 'string' || !HASH.test(e.digest)
20
+ || typeof e.inspectedAt !== 'string' || !Number.isSafeInteger(e.startLine) || !Number.isSafeInteger(e.endLine)))
21
+ fail('Invalid pair provenance');
22
+ }
23
+ function manifest(value) {
24
+ canonical(value);
25
+ if (!value || value.version !== 1 || !value.files || Object.keys(value.files).sort().join(',') !== 'research.html,surface.json'
26
+ || !HASH.test(value.files['research.html']) || !HASH.test(value.files['surface.json'])
27
+ || Object.keys(value).some(k => !['version', 'files', 'provenance', 'note', 'predecessor'].includes(k))
28
+ || (value.note && (!safeName(value.note.file) || !HASH.test(value.note.digest)))
29
+ || (value.predecessor && (!safeName(value.predecessor.directory) || !HASH.test(value.predecessor.manifestDigest))))
30
+ fail('Invalid Surface pair manifest');
31
+ checkProvenance(value.provenance);
32
+ }
33
+ /** No symlink ancestry; stable directory topology is required during synchronous operations. */
34
+ function checkDirectories(directory, stamps) {
35
+ const paths = [];
36
+ for (let p = directory;; p = dirname(p)) {
37
+ paths.unshift(p);
38
+ if (dirname(p) === p)
39
+ break;
40
+ }
41
+ for (const p of paths) {
42
+ let stat;
43
+ try {
44
+ stat = fs.lstatSync(p);
45
+ }
46
+ catch (e) {
47
+ if (e.code === 'ENOENT' && !stamps?.has(p))
48
+ continue;
49
+ throw e;
50
+ }
51
+ if (!stat.isDirectory() || stat.isSymbolicLink())
52
+ fail('Surface directory is occupied or a symbolic link');
53
+ const stamp = `${stat.dev}:${stat.ino}`, old = stamps?.get(p);
54
+ if (old && old !== stamp)
55
+ fail('Surface directory changed; reopen before saving');
56
+ stamps?.set(p, stamp);
57
+ }
58
+ }
59
+ function read(path, limit = LIMIT) {
60
+ if (fs.lstatSync(path).isSymbolicLink())
61
+ fail('Pair member cannot be a symbolic link');
62
+ return readBytes(path, limit);
63
+ }
64
+ function utf8(bytes) { try {
65
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
66
+ }
67
+ catch {
68
+ fail('Pair is not UTF-8');
69
+ } }
70
+ export function readSurfacePair(path) {
71
+ path = resolve(path);
72
+ if (!['research.html', 'surface.json'].includes(basename(path)))
73
+ fail('Select research.html or surface.json from a saved pair');
74
+ const directory = dirname(path);
75
+ checkDirectories(directory);
76
+ const bytes = {};
77
+ bytes['manifest.json'] = read(join(directory, 'manifest.json'), 512 * 1024);
78
+ let m;
79
+ try {
80
+ m = JSON.parse(utf8(bytes['manifest.json']));
81
+ }
82
+ catch {
83
+ fail('Invalid pair manifest JSON');
84
+ }
85
+ manifest(m);
86
+ for (const name of ['research.html', 'surface.json']) {
87
+ bytes[name] = read(join(directory, name));
88
+ if (hash(bytes[name]) !== m.files[name])
89
+ fail('Pair bytes conflict with manifest hash; preserve edits and reopen an accepted revision');
90
+ }
91
+ const research = readResearchHtml(utf8(bytes['research.html'])), surface = decode(utf8(bytes['surface.json']), 'json');
92
+ if (surface.contract !== 'n8r.surface/0.1')
93
+ fail('Expected Surface definition');
94
+ if (surface.records.length !== 1 || surface.records[0].id !== research.id)
95
+ fail('This host reopens one linked research record per pair');
96
+ verifyReferences(surface, [research]);
97
+ return { research, surface, directory, researchPath: join(directory, 'research.html'), surfacePath: join(directory, 'surface.json'), manifest: m, bytes };
98
+ }
99
+ export class SurfaceStore {
100
+ note;
101
+ directory;
102
+ previous;
103
+ stamps = new Map();
104
+ constructor(vault, source, previous, note) {
105
+ this.note = note;
106
+ source = fs.realpathSync(source);
107
+ const slug = basename(source).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || 'source';
108
+ this.directory = previous ? dirname(previous.directory) : join(fs.realpathSync(vault), 'surfaces', slug);
109
+ const rel = relative(source, this.directory);
110
+ if (fs.statSync(source).isDirectory() && (!rel || (rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep))))
111
+ fail('Surface cannot be saved inside selected source');
112
+ this.previous = previous;
113
+ checkDirectories(this.directory, this.stamps);
114
+ }
115
+ unchanged() {
116
+ checkDirectories(this.directory, this.stamps);
117
+ if (this.note && !read(this.note.path, 256 * 1024).equals(Buffer.from(this.note.text)))
118
+ fail('Original note changed; reopen before saving');
119
+ if (this.previous)
120
+ for (const name of MEMBERS)
121
+ if (!read(join(this.previous.directory, name)).equals(this.previous.bytes[name]))
122
+ fail('Predecessor changed; reopen before saving');
123
+ }
124
+ save(draft, provenance) {
125
+ this.unchanged();
126
+ checkProvenance(provenance);
127
+ verifyReferences(draft.surface, [draft.research]);
128
+ if (this.previous)
129
+ verifyPairSuccessor(this.previous, draft);
130
+ else if (draft.research.revision.parent || draft.surface.revision.parent)
131
+ fail('Initial pair cannot have an unavailable predecessor');
132
+ const html = renderResearchHtml(draft.research), json = encode(draft.surface, 'json');
133
+ if (Buffer.byteLength(html) > LIMIT)
134
+ fail('Research HTML exceeds host size limit');
135
+ const m = { version: 1, files: { 'research.html': hash(html), 'surface.json': hash(json) }, provenance,
136
+ ...(this.note ? { note: { file: basename(this.note.path), digest: this.note.digest } } : this.previous?.manifest.note ? { note: this.previous.manifest.note } : {}),
137
+ ...(this.previous ? { predecessor: { directory: basename(this.previous.directory), manifestDigest: hash(this.previous.bytes['manifest.json']) } } : {}) };
138
+ manifest(m);
139
+ const data = { 'research.html': html, 'surface.json': json, 'manifest.json': JSON.stringify(m, null, 2) + '\n' };
140
+ if (Buffer.byteLength(data['manifest.json']) > 512 * 1024)
141
+ fail('Pair manifest exceeds size limit');
142
+ fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
143
+ checkDirectories(this.directory, this.stamps);
144
+ const names = fs.readdirSync(this.directory);
145
+ if (names.length > 10000)
146
+ fail('Too many Surface revisions');
147
+ const max = names.reduce((n, name) => Math.max(n, Number(/^surface-(\d+)$/.exec(name)?.[1] ?? 0)), 0);
148
+ if (!Number.isSafeInteger(max + 1))
149
+ fail('Surface numbering limit reached');
150
+ const target = join(this.directory, `surface-${String(max + 1).padStart(3, '0')}`);
151
+ const temporary = fs.mkdtempSync(join(this.directory, '.surface-'));
152
+ try {
153
+ for (const name of MEMBERS) {
154
+ const fd = fs.openSync(join(temporary, name), 'wx', 0o600);
155
+ try {
156
+ fs.writeFileSync(fd, data[name]);
157
+ fs.fsyncSync(fd);
158
+ }
159
+ finally {
160
+ fs.closeSync(fd);
161
+ }
162
+ }
163
+ readSurfacePair(join(temporary, 'surface.json'));
164
+ this.unchanged();
165
+ // Under the stable-topology assumption, never replace any occupied destination.
166
+ if (fs.existsSync(target))
167
+ fail('Surface destination occupied');
168
+ fs.renameSync(temporary, target);
169
+ const saved = readSurfacePair(join(target, 'surface.json'));
170
+ for (const name of MEMBERS)
171
+ if (!saved.bytes[name].equals(Buffer.from(data[name])))
172
+ fail('Saved pair readback differs; inspect output before retrying');
173
+ this.previous = saved;
174
+ return saved;
175
+ }
176
+ finally {
177
+ if (fs.existsSync(temporary))
178
+ fs.rmSync(temporary, { recursive: true });
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,4 @@
1
+ import type { Artefact } from './contracts.js';
2
+ export declare function validateArtefact(input: unknown): Artefact;
3
+ /** Explicit offline bundle verification. Missing external context is an error here. */
4
+ export declare function verifyReferences(input: unknown, context: unknown[]): void;
@@ -0,0 +1,184 @@
1
+ import { canonical, digest, fail, portable } from './portable.js';
2
+ const text = (v, p) => { if (typeof v !== 'string' || !v.trim())
3
+ fail(p, 'requires nonempty string'); };
4
+ const match = (pattern, label) => (v, p) => { text(v, p); if (!pattern.test(v))
5
+ fail(p, label); };
6
+ const id = match(/^[a-z][a-z0-9+.-]*:[^\s]+$/i, 'requires namespaced identifier');
7
+ const token = match(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'requires revision/version token');
8
+ const hash = match(/^sha256:[a-f0-9]{64}$/, 'requires SHA-256 digest');
9
+ const time = (v, p) => {
10
+ text(v, p);
11
+ if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(v) || !Number.isFinite(Date.parse(v)) || new Date(v).toISOString() !== v.replace('Z', '.000Z'))
12
+ fail(p, 'requires real UTC timestamp with seconds');
13
+ };
14
+ const oneOf = (...values) => (v, p) => { if (!values.includes(v))
15
+ fail(p, `expected ${values.join('|')}`); };
16
+ const array = (check, min = 0) => (v, p) => {
17
+ if (!Array.isArray(v) || v.length < min)
18
+ fail(p, `requires array with at least ${min} entries`);
19
+ v.forEach((x, i) => check(x, `${p}[${i}]`));
20
+ };
21
+ function object(v, p) {
22
+ if (v === null || typeof v !== 'object' || Array.isArray(v))
23
+ fail(p, 'requires object');
24
+ return v;
25
+ }
26
+ const shape = (required, optional = {}) => (v, p) => {
27
+ const data = object(v, p);
28
+ for (const [key, check] of Object.entries(required)) {
29
+ if (!Object.hasOwn(data, key))
30
+ fail(`${p}.${key}`, 'required');
31
+ check(data[key], `${p}.${key}`);
32
+ }
33
+ for (const key of Object.keys(data)) {
34
+ if (Object.hasOwn(required, key))
35
+ continue;
36
+ if (!Object.hasOwn(optional, key))
37
+ fail(`${p}.${key}`, 'unsupported field; preserve additions in extensions');
38
+ optional[key](data[key], `${p}.${key}`);
39
+ }
40
+ };
41
+ const dictionary = (keyCheck, valueCheck) => (v, p) => {
42
+ for (const [k, value] of Object.entries(object(v, p))) {
43
+ keyCheck(k, `${p} key`);
44
+ valueCheck(value, `${p}.${k}`);
45
+ }
46
+ };
47
+ const value = (v, p) => portable(v, p);
48
+ const state = oneOf('known', 'unknown', 'omitted', 'redacted', 'absent', 'out-of-scope');
49
+ const ref = shape({ id, revision: token, digest: hash });
50
+ const revision = shape({ id: token, parent: (v, p) => { if (v !== null)
51
+ ref(v, p); }, createdAt: time, author: text, reason: text });
52
+ const common = { id, title: text, revision, scope: text, extensions: dictionary(id, value) };
53
+ const evidence = shape({ source: id, locator: text });
54
+ const location = (v, p) => {
55
+ text(v, p);
56
+ const s = v;
57
+ if (/^https?:\/\//.test(s)) {
58
+ const url = new URL(s);
59
+ if (url.username || url.password)
60
+ fail(p, 'credentials in source URL');
61
+ return;
62
+ }
63
+ if (s.startsWith('urn:')) {
64
+ id(v, p);
65
+ return;
66
+ }
67
+ if (s.startsWith('/') || /[\\:?#%]/.test(s) || s.split('/').some(part => !part || part === '.' || part === '..'))
68
+ fail(p, 'requires portable relative path or http(s)/urn reference');
69
+ };
70
+ const finding = (v, p) => {
71
+ shape({ id, subject: id, property: text, state: oneOf('known', 'unknown', 'omitted', 'redacted', 'absent', 'out-of-scope', 'conflicting'), note: text,
72
+ claims: array(shape({ value, evidence: array(evidence, 1) })) })(v, p);
73
+ const f = v;
74
+ const distinct = new Set(f.claims.map(c => canonical(c.value))).size;
75
+ if ((f.state === 'known' || f.state === 'absent') && distinct !== 1)
76
+ fail(p, 'known/absent requires one agreed claim value');
77
+ if (f.state === 'conflicting' && distinct < 2)
78
+ fail(p, 'conflicting requires differing sourced claims');
79
+ if (['unknown', 'omitted', 'redacted', 'out-of-scope'].includes(f.state) && f.claims.length)
80
+ fail(p, 'non-value state must not carry claims');
81
+ };
82
+ const research = shape({
83
+ ...common, contract: oneOf('n8r.research/0.1'),
84
+ sources: array(shape({ id, location, version: text, digest: hash, inspectedAt: time,
85
+ coverage: array(shape({ path: text, status: oneOf('inspected', 'missing', 'unreadable', 'excluded', 'truncated', 'unsupported'), note: text }), 1) })),
86
+ findings: array(finding),
87
+ alternatives: array(shape({ id, title: text, findings: array(id), requirements: array(text) })),
88
+ decisions: array(shape({ id, alternative: id, choice: oneOf('selected', 'rejected', 'deferred'), reason: text, by: text, at: time })),
89
+ questions: array(shape({ id, text, findings: array(id) })),
90
+ notes: array(shape({ by: text, at: time, text }))
91
+ });
92
+ const fact = (v, p) => {
93
+ shape({ state, note: text }, { value })(v, p);
94
+ const f = object(v, p);
95
+ if ((f.state === 'known') !== Object.hasOwn(f, 'value'))
96
+ fail(p, 'only known facts require a value');
97
+ };
98
+ const entity = { id, type: shape({ vocabulary: id, term: text }), attributes: dictionary(text, fact),
99
+ basis: array(shape({ record: id, revision: token, items: array(id, 1) })), requirements: array(id) };
100
+ const surface = shape({
101
+ ...common, contract: oneOf('n8r.surface/0.1'), kind: oneOf('definition'), records: array(ref),
102
+ vocabularies: array(shape({ id, version: token })),
103
+ nodes: array(shape({ ...entity, label: text })),
104
+ relationships: array(shape({ ...entity, from: id, to: id, claim: oneOf('declared') })),
105
+ requirements: array(shape({ id, kind: oneOf('secret', 'resource', 'configuration'), description: text, state })),
106
+ coverage: array(text), gaps: array(text)
107
+ });
108
+ function unique(ids, p) {
109
+ if (new Set(ids).size !== ids.length)
110
+ fail(p, 'duplicate identity/reference');
111
+ return new Set(ids);
112
+ }
113
+ function references(ids, available, p) {
114
+ unique(ids, p);
115
+ for (const item of ids)
116
+ if (!available.has(item))
117
+ fail(p, `unresolved reference ${item}`);
118
+ }
119
+ export function validateArtefact(input) {
120
+ canonical(input); // checks portable values, depth and logical size before field access
121
+ const data = object(input, '$');
122
+ if (data.contract === 'n8r.research/0.1') {
123
+ research(input, '$');
124
+ const r = input;
125
+ unique([r.id, ...r.sources.map(x => x.id), ...r.findings.map(x => x.id), ...r.alternatives.map(x => x.id), ...r.decisions.map(x => x.id), ...r.questions.map(x => x.id)], '$');
126
+ const sources = new Set(r.sources.map(x => x.id)), findings = new Set(r.findings.map(x => x.id)), alternatives = new Set(r.alternatives.map(x => x.id));
127
+ for (const f of r.findings)
128
+ for (const c of f.claims)
129
+ for (const e of c.evidence)
130
+ references([e.source], sources, f.id);
131
+ for (const a of [...r.alternatives, ...r.questions])
132
+ references(a.findings, findings, a.id);
133
+ for (const d of r.decisions)
134
+ references([d.alternative], alternatives, d.id);
135
+ unique(r.decisions.map(d => d.alternative), '$.decisions'); // one current decision per option; old decisions stay in prior revisions
136
+ }
137
+ else if (data.contract === 'n8r.surface/0.1') {
138
+ surface(input, '$');
139
+ const s = input;
140
+ unique([s.id, ...s.nodes.map(x => x.id), ...s.relationships.map(x => x.id), ...s.requirements.map(x => x.id)], '$');
141
+ const nodes = new Set(s.nodes.map(x => x.id)), vocab = unique(s.vocabularies.map(x => x.id), '$.vocabularies');
142
+ const requirements = new Set(s.requirements.map(x => x.id));
143
+ const records = unique(s.records.map(x => JSON.stringify([x.id, x.revision])), '$.records');
144
+ for (const e of [...s.nodes, ...s.relationships]) {
145
+ references([e.type.vocabulary], vocab, e.id);
146
+ references(e.requirements, requirements, e.id);
147
+ for (const b of e.basis) {
148
+ references([JSON.stringify([b.record, b.revision])], records, e.id);
149
+ unique(b.items, e.id);
150
+ }
151
+ }
152
+ for (const e of s.relationships) {
153
+ references([e.from], nodes, e.id);
154
+ references([e.to], nodes, e.id);
155
+ }
156
+ }
157
+ else
158
+ fail('$.contract', 'unsupported contract/version');
159
+ const a = input;
160
+ if (a.revision.parent && (a.revision.parent.id !== a.id || a.revision.parent.revision === a.revision.id))
161
+ fail('$.revision.parent', 'requires earlier revision of the same artefact');
162
+ return a;
163
+ }
164
+ /** Explicit offline bundle verification. Missing external context is an error here. */
165
+ export function verifyReferences(input, context) {
166
+ const a = validateArtefact(input);
167
+ const records = context.map(validateArtefact);
168
+ unique(records.map(r => JSON.stringify([r.id, r.revision.id])), '$context');
169
+ if (a.contract !== 'n8r.surface/0.1')
170
+ return;
171
+ for (const ref of a.records) {
172
+ const r = records.find(x => x.id === ref.id && x.revision.id === ref.revision);
173
+ if (!r || r.contract !== 'n8r.research/0.1')
174
+ fail(ref.id, 'research revision unavailable');
175
+ if (digest(r) !== ref.digest)
176
+ fail(ref.id, 'research digest mismatch');
177
+ const items = new Set([...r.findings, ...r.alternatives, ...r.decisions, ...r.questions].map(x => x.id));
178
+ for (const e of [...a.nodes, ...a.relationships])
179
+ for (const b of e.basis) {
180
+ if (b.record === ref.id && b.revision === ref.revision)
181
+ references(b.items, items, e.id);
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,133 @@
1
+ # n8r-surface capability map
2
+
3
+ `n8r-surface` is the proposed standalone tool for researching software and
4
+ authoring partial AI Surface Model descriptions with a human. Researcher is its
5
+ shared discovery skill. The tool can run independently and later exchange work
6
+ with n8r-labs.
7
+
8
+ The [shareable skill](researcher-skill.md) has bounded existing-harness evidence.
9
+ On 5 November 2026 the user prioritized standalone AI integration, then complete
10
+ save/reopen/continue verification, then npm distribution. Later n8r-labs adaptation
11
+ uses the same core in that separate repository.
12
+
13
+ **Status, 5 November 2026:** #15 supplies experimental artefact contracts,
14
+ JSON/YAML codecs, HTML ownership checks and synthetic cases. The command implements
15
+ [bare startup and vault setup](startup.md), plus a
16
+ [bounded Codex conversation](codex-research-verification.md). Saved continuation,
17
+ complete structured research and Lab integration remain outstanding.
18
+ [Skill verification](researcher-skill-verification.md) distinguishes instruction
19
+ trials from the pending Work-mode lifecycle trial. This is unreleased work;
20
+ the remaining map describes intended capabilities.
21
+
22
+ - [Experimental contracts and checks](contracts.md)
23
+ - [Command intent, outcomes and verification](command-outcomes.md)
24
+ - [Library verification evidence and remaining limits](verification.md)
25
+ - [Bare-startup installed-command evidence](startup-verification.md)
26
+ - [Synthetic acceptance cases](../../examples/researcher/README.md)
27
+ - [Host boundary and Lab review](host-contract.md)
28
+ - [Implementation decisions](implementation-decisions.md)
29
+ - [Next work and critical path](critical-path.md)
30
+ - [Design requirements and open choices](design.md)
31
+ - [Tracking issue #13](https://github.com/injectionator/n8r-surface/issues/13)
32
+ - [Map and planning issue #14](https://github.com/injectionator/n8r-surface/issues/14)
33
+
34
+ ## Capability map
35
+
36
+ The diagram shows the intended capabilities across hosts, not the delivery order.
37
+ The current skill produces working notes; structured artefacts remain later work.
38
+
39
+ ```mermaid
40
+ flowchart TD
41
+ Sources[Repository, folder or supplied documents] --> Read[Scoped source reading]
42
+ Human[Human questions and choices] --> Researcher[Researcher shared skill]
43
+ Read --> Researcher
44
+
45
+ subgraph Hosts[Entry points]
46
+ CLI[n8r-surface CLI]
47
+ Chat[Skill in a supported chat harness]
48
+ end
49
+ CLI --> Researcher
50
+ Chat --> Researcher
51
+ AI[One supported AI runner per host] --> Researcher
52
+ MoreAI[Additional AI runners] -.-> AI
53
+ Setup[Local setup and preferences] --> CLI
54
+
55
+ Researcher --> Record[HTML research record]
56
+ Record --> Compose[Select and refine a composition]
57
+ Human --> Compose
58
+ Compose --> Model[Partial AI Surface Model description]
59
+ Model --> JSON[JSON encoding]
60
+ Model --> YAML[YAML encoding]
61
+ Model -.-> Other[MessagePack and other mappings]
62
+
63
+ Record --> Corpus[Configurable artefact folder and recoverable revisions]
64
+ JSON --> Corpus
65
+ YAML --> Corpus
66
+ Corpus --> Resume[Find, read and continue saved work]
67
+ Resume --> Researcher
68
+
69
+ Corpus -.-> Exchange[Explicit import, export and handoff]
70
+ Exchange -.-> Lab[n8r-labs application: separate project]
71
+ Lab -.-> Exchange
72
+ Lab -.-> Researcher
73
+ ```
74
+
75
+ The AI runner and the destination for results are independent choices. A user
76
+ can research locally with their chosen supported AI, save files and later send
77
+ selected work to a Lab. A Lab connection is optional for standalone authoring.
78
+
79
+ ## Delivery boundaries
80
+
81
+ | Capability | Delivery position | Evidence required |
82
+ | --- | --- | --- |
83
+ | HTML research record and partial graph contract | Experimental implementation: [#15](https://github.com/injectionator/n8r-surface/issues/15) | Synthetic examples have explicit sources, decisions, gaps and stable references. |
84
+ | JSON and YAML representations | Codec and contract in #15; AI-authored output in #17 | Supported meanings survive conversion, including relationship attributes and unknowns. |
85
+ | TypeScript CLI with npm/npx distribution | Agreed; implement in #16 | Installed package and bundled assets work on supported Node.js versions without the source checkout or Bun. |
86
+ | Startup, source access and safe artefact tools | Paused remaining work: [#16](https://github.com/injectionator/n8r-surface/issues/16) | Setup, path handling, conflict detection and recovery work in temporary folders. |
87
+ | Researcher shared skill | First: [#17](https://github.com/injectionator/n8r-surface/issues/17) | Installed skill invocation, evidence-backed notes, human correction and save/reopen. |
88
+ | Standalone CLI runner | After skill and Lab adaptation | Real discovery, human correction, guarded save/resume and model output work without a Lab. |
89
+ | ChatGPT desktop / Work with supplied sources | First lifecycle-test host, in #17 | The same core skill produces usable records and descriptions in the actual host. |
90
+ | More AI runners and external harnesses | Later | Each adapter has demonstrated authentication, tool use and continuation. |
91
+ | Standalone executables | Later distribution option | Each supported platform runs the packaged tool and required assets without a separate Node.js installation. |
92
+ | MessagePack and additional encodings | Later, when a consumer needs them | A bounded mapping preserves meanings or reports unsupported information. |
93
+ | Researcher in n8r-labs and artefact exchange | Application stage after the skill trial, before standalone CLI | The application uses the versioned core and passes shared import/continuation cases. |
94
+ | Optional remote Researcher execution | Later integration | Source access, authentication and session scope are demonstrated against a named endpoint version. |
95
+ | Rich editing, automatic synchronisation and advanced corpus search | Outside the first path | A concrete use case justifies extending the simple folder workflow. |
96
+
97
+ ## Repository ownership
98
+
99
+ This repository owns the standalone CLI, portable Researcher skill, artefact
100
+ contracts, model encodings and consumer-side integration. The AI Surface Model
101
+ remains the one model; the CLI name does not rename it.
102
+
103
+ The n8r-labs project owns its application assistant, attachment UI, source access,
104
+ Lab sessions, storage, permissions and API/MCP endpoints. This repository
105
+ maintains the versioned Researcher core, artefact contracts and shared examples.
106
+ The application later consumes an identified revision through its own adapters;
107
+ it does not need to host the CLI or adopt its local folder layout.
108
+
109
+ Keep the [compatibility contract](design.md#compatibility-contract) current as
110
+ the tool develops. Review Lab requirements during #15 and carry the same
111
+ acceptance examples into application integration. Compatibility remains a goal
112
+ until a named application version passes those cases. Do not copy private
113
+ application prompts or operational records here.
114
+
115
+ ## Keeping the map useful
116
+
117
+ Issue #13 is the overview, this page is the capability map, and
118
+ [critical-path.md](critical-path.md) names the next delivery gate. Detailed
119
+ requirements live in [design.md](design.md); implementation issues carry their
120
+ own acceptance checks.
121
+
122
+ Update these documents in the PR that changes a capability or delivery decision.
123
+ Record implemented behaviour only with its verification evidence. Keep later
124
+ ideas on this map until they are ready to become bounded issues. A draft PR,
125
+ passing fixture checks and a working live integration are distinct states.
126
+
127
+ Use [State X → Input Y → verified Outcome Z](command-outcomes.md) as the acceptance
128
+ structure for each command increment. Attempt and repeat the actual product path,
129
+ and verify changed state independently. The current fixture/library checks do not
130
+ establish a successful repository-research command.
131
+
132
+ Related: [model overview](../../README.md), [draft concepts](../../spec/README.md),
133
+ [model roadmap](../roadmap.md), [interoperability](../interoperability.md).
@@ -0,0 +1,51 @@
1
+ # Installed Codex conversation transcript
2
+
3
+ Agent rehearsal, 5 November 2026. Synthetic source only; the displayed absolute
4
+ source path is replaced with `<selected-source>`. Other conversation text is
5
+ retained. This is not human acceptance. See [environment and readback](codex-research-verification.md).
6
+
7
+ ```text
8
+ Research source: <selected-source>/lumen-guide.md (one file)
9
+ Inspected source text and your messages will be sent to OpenAI through Codex using ChatGPT sign-in.
10
+ Source access is read-only. Saving and reopening are not available yet.
11
+ Start research with OpenAI using Codex ChatGPT sign-in? [y/N] y
12
+
13
+ Connecting to Codex…
14
+ Connected: ChatGPT sign-in; model gpt-6-astra.
15
+ Researcher 0.1.8; sha256:8af29c3073df9b8210a7dd94ced9700379b0a98f76bd3a2902f9430b93c98a2a
16
+ Ask about your source. /exit ends the chat; Ctrl-C cancels.
17
+ You> Describe the AI-relevant components and connections in the supplied source, citing file lines and distinguishing selected connections from options. Keep it concise.
18
+
19
+ I’m using `n8r-surface:research 0.1.8` to map the supplied source, separating selected connections from documented options.
20
+ [source_list] .
21
+ [source_read] lumen-guide.md sha256:98d3374c79c166b37f0debb4ffb5e457eda1385dd7847b042cbad674cac98693
22
+ ## Partial AI Surface
23
+
24
+ Scope: fictional **Lumen Desk**, guide revision `fixture-1`; not a real installation (`lumen-guide.md:1–4`).
25
+
26
+ | Connection | Status and purpose | Evidence |
27
+ |---|---|---|
28
+ | Lumen Desk → inference endpoint | **Declared configured:** generates summaries. Model and version unspecified. | `lumen-guide.md:8–9` |
29
+ | Lumen Desk → external OIDC service | **Supported option**, not an established selection: identity service. | `lumen-guide.md:9` |
30
+ | Lumen Desk → archive service | **Delivery option:** summary documents and audit events use separate HTTP routes on one service. Schema/authentication require agreement; no tested Harbor Archive integration. Document batches capped at 10. | `lumen-guide.md:13–16` |
31
+ | Archive → callback receiver | **Optional, disabled until selected and configured:** receives archive notifications. Validation, retries and tested protocol unspecified. | `lumen-guide.md:20–22` |
32
+
33
+ **Limits:** Input sources and processing steps before inference are unknown. No deployment or runtime observations establish selected installation connections or exercised behavior (`lumen-guide.md:26–28`).
34
+
35
+ Entire supplied file inspected, without truncation. SHA-256: `98d3374c79c166b37f0debb4ffb5e457eda1385dd7847b042cbad674cac98693`.
36
+
37
+ Research stays in this conversation; no save capability is available.
38
+ You> For this investigation I choose not to use external OIDC. Retain that as my decision, not a change to the guide. Which authentication facts remain unknown?
39
+
40
+ **Human decision — 2026-11-05:** External OIDC is not selected for this investigation. The guide still describes it as a supported option (`lumen-guide.md:9`).
41
+
42
+ **Authentication facts still unknown:**
43
+ - **Lumen user access:** whether authentication is required and what, if anything, replaces external OIDC.
44
+ - **Inference endpoint:** authentication mechanism and caller identity; the guide only declares a configured endpoint (`lumen-guide.md:8–9`).
45
+ - **Archive delivery, if selected:** authentication configuration and service identity remain unspecified and require agreement (`lumen-guide.md:13–15`).
46
+ - **Callbacks, if enabled:** sender authentication and notification validation are unspecified; callbacks remain optional (`lumen-guide.md:20–22`).
47
+ - **Effective runtime configuration:** no deployment or runtime observations are supplied (`lumen-guide.md:26–28`).
48
+
49
+ Not selecting OIDC does **not** establish that access is unauthenticated. Your decision is retained here separately from source facts.
50
+ You> /exit
51
+ ```