Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/ingest-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,13 @@ packages/ingest-cli/
```

The agent-facing extraction prompts that produce the `--from <result.json>` input live with the skill, at `transitrix/skills/ingest/prompts/`.

## Intake compatibility

`transitrix-intake-profile check <org-root> --profile ingest|knowledge-store` is a read-only
preflight for separate ingestion workspaces. `transitrix-intake-profile select` accepts the
same arguments and records the checked selection in `_intake/profile.json`.
`scaffold-intake` enforces and records `ingest` before creating any directories.
Neither command changes existing content or ignore rules. OKF checks require Git
so ignored source records cannot be silently hidden. See the
[deployment contract](../../patterns/knowledge-store.md#deployment-profiles-and-intake-compatibility).
19 changes: 19 additions & 0 deletions packages/ingest-cli/intake-profile.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env node
// Standalone preflight: no vocabulary or extraction dependencies.
import { checkIntakeProfile, selectIntakeProfile } from './src/intake-profile.mjs';

const [command, root, flag, profile, ...extra] = process.argv.slice(2);
if (command === '--help') {
console.log('transitrix-intake-profile check|select <org-root> --profile ingest|knowledge-store');
} else if (!['check', 'select'].includes(command) || !root || flag !== '--profile' || !profile || extra.length) {
console.error('Usage: transitrix-intake-profile check|select <org-root> --profile ingest|knowledge-store');
process.exitCode = 1;
} else {
try {
const operation = command === 'select' ? selectIntakeProfile : checkIntakeProfile;
console.log(JSON.stringify(await operation(root, profile), null, 2));
} catch (error) {
console.error(error.message);
process.exitCode = 2;
}
}
4 changes: 3 additions & 1 deletion packages/ingest-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
"description": "Deterministic CLI for the Transitrix ingest skill — scaffold intake, convert documents, emit field artefacts and canon candidates, validate, and stage a review queue. Never writes canon.",
"type": "module",
"bin": {
"transitrix-ingest": "./ingest.mjs"
"transitrix-ingest": "./ingest.mjs",
"transitrix-intake-profile": "./intake-profile.mjs"
},
"engines": {
"node": ">=20"
},
"files": [
"ingest.mjs",
"intake-profile.mjs",
"src/",
"assets/"
],
Expand Down
106 changes: 106 additions & 0 deletions packages/ingest-cli/src/intake-profile.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Skill-local deployment compatibility. Read-only checks precede every setup write.
import { lstat, readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
import { join, resolve, relative } from 'node:path';
import { spawnSync } from 'node:child_process';

const PROFILES = ['ingest', 'knowledge-store'];
const MARKER = '_intake/profile.json';
const fail = message => { throw new Error(`Intake profile conflict: ${message}. Use separate workspaces; existing data was not changed.`); };

async function inspect(path) {
try {
const stat = await lstat(path);
if (stat.isSymbolicLink()) fail(`cannot establish ownership through symlink ${path}`);
return stat;
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}

async function files(path) {
const stat = await inspect(path);
if (!stat) return [];
if (!stat.isDirectory()) return [path];
const result = [];
for (const name of await readdir(path)) {
if (name === '.gitkeep' || name === '.gitignore') {
await inspect(join(path, name));
continue;
}
result.push(...await files(join(path, name)));
}
return result;
}

export async function checkIntakeProfile(orgRoot, profile) {
if (!PROFILES.includes(profile)) throw new Error('Choose --profile ingest or --profile knowledge-store');
const root = resolve(orgRoot);
await inspect(root);
const intake = await inspect(join(root, '_intake'));
if (intake && !intake.isDirectory()) fail('_intake is not a directory');
const evidence = { ingest: [], 'knowledge-store': [] };
let declared = null;
if (await inspect(join(root, MARKER))) {
const marker = JSON.parse(await readFile(join(root, MARKER), 'utf8'));
if (marker.version !== 1 || !PROFILES.includes(marker.profile)) fail('invalid profile marker');
declared = marker.profile;
evidence[declared].push(MARKER);
}
for (const [owner, paths] of Object.entries({
ingest: ['_intake/processing', '_intake/snapshots', 'operations/config/scan-sources.yaml'],
'knowledge-store': ['knowledge', '_intake/originals', '_intake/drafts', '_intake/log.md'],
})) {
for (const path of paths) if (await inspect(join(root, path))) evidence[owner].push(path);
}
for (const name of ['inbox', 'processing', 'processed', 'snapshots', 'originals', 'drafts']) {
const stat = await inspect(join(root, '_intake', name));
if (stat && !stat.isDirectory()) fail(`_intake/${name} is not a directory`);
}
const processed = await files(join(root, '_intake/processed'));
for (const path of processed) {
const text = path.endsWith('.md') ? await readFile(path, 'utf8') : '';
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1] || '';
const okf = /^(?:type|["']type["']):[ \t]*(?:"source-document"|'source-document'|source-document)[ \t]*(?:#.*)?$/m.test(frontmatter);
if (!okf && frontmatter.includes('source-document')) fail(`ambiguous source record at ${relative(root, path)}`);
evidence[okf ? 'knowledge-store' : 'ingest'].push(relative(root, path));
}
// Unknown workspace entries must be classified by the adopter, never guessed.
if (intake) {
const known = new Set(['profile.json', '.gitkeep', '.gitignore', 'README.md', 'inbox', 'processed', 'processing', 'snapshots', 'originals', 'drafts', 'log.md']);
for (const name of await readdir(join(root, '_intake'))) {
if (!known.has(name)) fail(`unrecognised _intake/${name}`);
await inspect(join(root, '_intake', name));
}
}
const other = profile === 'ingest' ? 'knowledge-store' : 'ingest';
if (evidence[other].length) fail(`${profile} requested; ${other} evidence at ${evidence[other].join(', ')}`);

// Both profiles require a marker that survives a normal commit and clone.
const markerVisibility = spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '--', MARKER], {
encoding: 'utf8',
});
if (markerVisibility.status === 0) fail(`profile marker would be ignored: ${MARKER}`);
if (markerVisibility.status !== 1) fail('cannot verify profile marker visibility; use a Git workspace and retry the check');

if (profile === 'knowledge-store') {
// Git evaluates nested/global ignores and negations, including tracked files.
const paths = ['_intake/processed/profile-check.md', ...processed.map(p => relative(root, p))];
const result = spawnSync('git', ['-C', root, 'check-ignore', '--no-index', '--stdin'], {
input: paths.join('\n') + '\n', encoding: 'utf8',
});
if (result.status === 0) fail(`OKF source records would be ignored: ${result.stdout.trim()}`);
if (result.status !== 1) fail('cannot verify OKF record visibility; use a Git workspace and retry the check');
}
return { profile, declared, evidence: evidence[profile] };
}

export async function selectIntakeProfile(orgRoot, profile) {
const result = await checkIntakeProfile(orgRoot, profile);
if (!result.declared) {
await mkdir(join(resolve(orgRoot), '_intake'), { recursive: true });
// Exclusive creation prevents replacing a concurrent or existing selection.
await writeFile(join(resolve(orgRoot), MARKER), JSON.stringify({ version: 1, profile }, null, 2) + '\n', { flag: 'wx' });
}
return result;
}
Loading
Loading