Skip to content
Open
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
25 changes: 25 additions & 0 deletions .changeset/publish-multi-search-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'tenjin-cli': patch
---

One piece can now claim every search it answered.

`tenjin publish --search-id` repeats, up to ten searches per piece. A research
thread fans out into many searchIds — one investigation last month logged ten
MISSes for what was a single question — and until now the piece published back
against one of them while the other nine were closed one at a time as
`regenerated`. That was false twice over: they were not re-derived, they were
answered by the same piece, and MISS-to-publish conversion counted every sibling
of the thread as a failure.

Every named id closes its own local loop with publish attribution, re-linking one
an `outcome` already closed exactly as a single id does today, and the JSON
envelope reports each id separately (`searches`), so an id this machine has never
recorded warns for itself instead of taking the rest down with it. A lone id
keeps the flat `search` field callers already read. Repeats collapse. A `--draft`
still answers nobody and sends no attribution at all.

On the wire, one id ships as the bare string it has always been, so a
single-search publish is byte-identical against a server that predates this;
several ship as an array, which needs the array-accepting post-create deployed
first.
38 changes: 19 additions & 19 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,25 +164,25 @@ Reports how a search ended.

Publishes Markdown with optional metadata and a local safety scan. Hard blocks cannot be bypassed.

| Flag | Effect |
| -------------------------- | --------------------------------------------------- |
| `--search-id <uuid>` | Link the piece to the search it answers. |
| `--draft` | Save privately instead of publishing. |
| `--price <usd>` | Set the post price. |
| `--excerpt <text>` | Public preview. |
| `--mode <mode>` | `review`, `auto`, or `full-auto` for this run. |
| `--yes` | Clear warning findings and the review confirmation. |
| `--question <text>` | Question this piece answers. Repeatable. |
| `--task <text>` | Task this piece supports. Repeatable. |
| `--scope <text>` | What the piece covers. |
| `--exclusions <text>` | What it does not cover. |
| `--applies-to <key=v1,v2>` | Applicability metadata. Repeatable. |
| `--as-of <time>` | When evidence was gathered. |
| `--valid-until <time>` | When the answer expires. |
| `--artifact-type <type>` | `document`, `skill`, or `dataset`. |
| `--temporal-mode <mode>` | `snapshot`, `maintained`, or `evergreen`. |
| `--provenance <text>` | How evidence was obtained. |
| `--methodology <text>` | How it was established. |
| Flag | Effect |
| -------------------------- | ------------------------------------------------------------------------ |
| `--search-id <uuid>` | Link the piece to the search it answers. Repeatable, up to 10 per piece. |
| `--draft` | Save privately instead of publishing. |
| `--price <usd>` | Set the post price. |
| `--excerpt <text>` | Public preview. |
| `--mode <mode>` | `review`, `auto`, or `full-auto` for this run. |
| `--yes` | Clear warning findings and the review confirmation. |
| `--question <text>` | Question this piece answers. Repeatable. |
| `--task <text>` | Task this piece supports. Repeatable. |
| `--scope <text>` | What the piece covers. |
| `--exclusions <text>` | What it does not cover. |
| `--applies-to <key=v1,v2>` | Applicability metadata. Repeatable. |
| `--as-of <time>` | When evidence was gathered. |
| `--valid-until <time>` | When the answer expires. |
| `--artifact-type <type>` | `document`, `skill`, or `dataset`. |
| `--temporal-mode <mode>` | `snapshot`, `maintained`, or `evergreen`. |
| `--provenance <text>` | How evidence was obtained. |
| `--methodology <text>` | How it was established. |

### `tenjin edit <post-id>`

Expand Down
6 changes: 4 additions & 2 deletions skills/tenjin-publish/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,10 @@ tenjin publish <file.md> --json [--search-id <id>] [--draft]
Pass `--search-id <id>` when the piece answers a search that MISSed: it closes
that loop, prefills the searched question into `questionsAnswered` when the draft
names none, and travels to the server as this piece's attribution. It re-links a
loop an `outcome` already closed, so a premature close is recoverable. `--draft`
saves a private draft, leaves the loop open, and sends no attribution.
loop an `outcome` already closed, so a premature close is recoverable. Repeat it
(up to 10) when one thread fanned out into several searches this one piece
answers, rather than closing the siblings as `regenerated`. `--draft` saves a
private draft, leaves the loop open, and sends no attribution.

**On any exit 3, render THAT payload's findings and price as one yes/no, then
re-run with `--yes` on an explicit yes.** Never ask a generic "shall I publish?"
Expand Down
22 changes: 22 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,28 @@ describe('edit flag forwarding (the dispatcher mapping)', () => {
});
});

/**
* An option that did not collect would keep the LAST id and drop the rest. Read
* back through the cap refusal, which only eleven surviving ids can trip.
*/
describe('publish --search-id collects (the dispatcher mapping)', () => {
it('repeats rather than replacing, and the cap counts every id given', async () => {
const ids = Array.from(
{ length: 11 },
(_, i) => `0197bbbb-cccc-7ddd-8eee-0000000000${String(i).padStart(2, '0')}`,
);
const cap = captureIo();
const code = await main(
['publish', 'nope.md', ...ids.flatMap((id) => ['--search-id', id]), '--json'],
cap.io,
);
expect(code).toBe(2);
const parsed = JSON.parse(cap.stdout());
expect(parsed.command).toBe('publish');
expect(parsed.error.message).toContain('at most 10 searches (got 11)');
});
});

/**
* The `session` group. Dispatcher-level only: `session start` reaches a wallet,
* so the cases here are the ones that resolve BEFORE it — the group exists, the
Expand Down
8 changes: 6 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,9 @@ export function buildProgram(io: Io, setExit: (code: number) => void): Command {
)
.option(
'--search-id <id>',
'the search this file answers (closes its open loop, and prefills its question)',
'the search this file answers (closes its open loop, and prefills its question); repeatable, up to 10, when one piece answers a whole research thread',
collect,
[],
)
.option('--draft', 'save as a private draft instead of publishing')
.option('--yes', 'clear soft findings and the review confirm (never a hard block)')
Expand Down Expand Up @@ -500,7 +502,9 @@ export function buildProgram(io: Io, setExit: (code: number) => void): Command {
return runPublish(
{
...(typeof file === 'string' ? { file } : {}),
...(typeof o.searchId === 'string' ? { searchId: o.searchId } : {}),
...(Array.isArray(o.searchId) && o.searchId.length > 0
? { searchId: o.searchId as string[] }
: {}),
...(o.draft === true ? { draft: true } : {}),
...(o.yes === true ? { yes: true } : {}),
...(typeof o.mode === 'string' ? { mode: o.mode } : {}),
Expand Down
174 changes: 157 additions & 17 deletions src/commands/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ function stubServer(post: Record<string, unknown> = CREATED): {
return { fetch: fetchFn, calls };
}

/** A stub server that also captures the parsed request body. */
function bodyServer(): { fetch: typeof fetch; body: () => Record<string, unknown> | undefined } {
let captured: Record<string, unknown> | undefined;
const fetchFn = (async (_url: string | URL, init?: RequestInit) => {
captured = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
return new Response(JSON.stringify(CREATED), {
status: 201,
headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch;
return { fetch: fetchFn, body: () => captured };
}

function questionsIn(body: Record<string, unknown> | undefined): string[] | undefined {
return (body?.resource as { questionsAnswered?: string[] } | undefined)?.questionsAnswered;
}

async function writeDoc(content: string): Promise<string> {
const path = join(dir, 'post.md');
await writeFile(path, content, 'utf8');
Expand Down Expand Up @@ -588,23 +605,6 @@ describe('runPublish — publish <file> --search-id', () => {
});
}

/** A stub server that also captures the parsed request body. */
function bodyServer(): { fetch: typeof fetch; body: () => Record<string, unknown> | undefined } {
let captured: Record<string, unknown> | undefined;
const fetchFn = (async (_url: string | URL, init?: RequestInit) => {
captured = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
return new Response(JSON.stringify(CREATED), {
status: 201,
headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch;
return { fetch: fetchFn, body: () => captured };
}

function questionsIn(body: Record<string, unknown> | undefined): string[] | undefined {
return (body?.resource as { questionsAnswered?: string[] } | undefined)?.questionsAnswered;
}

// The gap this flag closes: the path the Stop hook and the auto-mode skill
// prescribe is a bare file publish, which left the loop open.
it('resolves the named search on a successful file publish', async () => {
Expand Down Expand Up @@ -886,6 +886,146 @@ describe('runPublish — publish <file> --search-id', () => {
});
});

/**
* One research thread fans out into many searchIds and a piece answers the
* thread, not one query of it (#167). The siblings used to be closed as
* `regenerated`, which reads as failures of a loop that actually converted.
*/
describe('runPublish — a piece that answers a whole thread', () => {
const A = '0197bbbb-cccc-7ddd-8eee-aaaaaaaaaaaa';
const B = '0197bbbb-cccc-7ddd-8eee-bbbbbbbbbbbb';
const C = '0197bbbb-cccc-7ddd-8eee-cccccccccccc';
const D = '0197bbbb-cccc-7ddd-8eee-dddddddddddd';

async function seed(searchId: string, question: string): Promise<void> {
await recordSearch(dir, {
searchId,
at: new Date().toISOString(),
question,
decision: 'MISS',
candidates: [],
});
}

function searchesIn(res: { data: unknown }): unknown[] | undefined {
return (res.data as { searches?: unknown[] }).searches;
}

async function publishWith(ids: string[], over: Partial<PublishArgs> = {}) {
const { fetch, body } = bodyServer();
const { ctx, stderr } = makeCtxCapturingStderr();
const res = await runPublish(
baseArgs(await writeDoc(CLEAN), { searchId: ids, mode: 'auto', ...over }),
ctx,
hermetic({ fetchImpl: fetch, provider: spyProvider().provider }),
);
return { res, body, stderr };
}

// The wire rule the server rollout depends on: a CLI that never names two
// keeps working against a post-create that only takes a scalar.
it('sends a bare string for one id and an array for several', async () => {
await seed(A, 'first');
const one = await publishWith([A]);
expect(one.body()?.searchId).toBe(A);

await seed(B, 'second');
const many = await publishWith([A, B]);
expect(many.body()?.searchId).toEqual([A, B]);
});

// A repeat collapses in the ledger too, not only on the wire.
it('collapses a repeated id on the wire and in the receipt', async () => {
await seed(A, 'first');
await seed(B, 'second');
const { res, body } = await publishWith([A, B, A]);
expect(body()?.searchId).toEqual([A, B]);
expect(searchesIn(res)).toHaveLength(2);
});

it('refuses more than ten searches before any wallet touch', async () => {
const ids = Array.from(
{ length: 11 },
(_, i) => `0197bbbb-cccc-7ddd-8eee-0000000000${String(i).padStart(2, '0')}`,
);
const { fetch, calls } = stubServer();
const { provider, getSignerCount } = spyProvider();
await expect(
runPublish(
baseArgs(await writeDoc(CLEAN), { searchId: ids, mode: 'auto' }),
makeCtx(),
hermetic({ fetchImpl: fetch, provider }),
),
).rejects.toMatchObject({ code: 'USAGE' });
expect(calls).toHaveLength(0);
expect(getSignerCount()).toBe(0);
});

// Four local states, differing in who holds the attribution. The id this
// machine never heard of must not take the rest down with it.
it('closes each named search on its own terms, absent ones included', async () => {
await seed(A, 'closeable');
await seed(B, 'closed by an outcome report');
await seed(C, 'closed by an earlier publish');
await markSearchResolved(dir, B, 'outcome');
await markSearchResolved(dir, C, 'publish');

const { res, stderr } = await publishWith([A, B, C, D]);

expect(searchesIn(res)).toEqual([
{ id: A, closed: true, prefill: 'applied' },
{ id: B, closed: true, relinked: true, prefill: 'none' },
{ id: C, closed: true, alreadyAnswered: true, prefill: 'none' },
{ id: D, closed: false, prefill: 'none' },
]);
const stored = await loadSearches(dir);
for (const id of [A, B, C]) {
expect(stored.find((s) => s.searchId === id)?.resolved?.by, id).toBe('publish');
}
expect(stderr()).toContain(`search ${D} is not in the local store`);
expect(res.humanLines).toContain(`Closed the loop on search ${A}.`);
expect(res.humanLines).toContain(
`Re-linked search ${B} to this piece; it had been closed without one.`,
);
expect(res.humanLines).toContain(`Search ${C} was already answered by an earlier publish.`);
});

// One card, so one prefill: only the first recorded search lends its phrasing.
it('prefills the card from the first stored search and says which one', async () => {
await seed(B, 'the phrasing that ships');
const { res, body } = await publishWith([A, B, C]);
expect(questionsIn(body())).toEqual(['the phrasing that ships']);
expect(searchesIn(res)).toEqual([
{ id: A, closed: false, prefill: 'none' },
{ id: B, closed: true, prefill: 'applied' },
{ id: C, closed: false, prefill: 'none' },
]);
});

it('sends no searchId on a multi-id draft and leaves every loop open', async () => {
await seed(A, 'first');
await seed(B, 'second');
const { res, body } = await publishWith([A, B], { draft: true });
expect(body()).not.toHaveProperty('searchId');
expect((await loadSearches(dir)).every((s) => s.resolved === undefined)).toBe(true);
expect(searchesIn(res)?.every((s) => (s as { closed: boolean }).closed === false)).toBe(true);
});

// `search` is what callers have read since #161: it survives for a lone id.
it('keeps the flat search field for one id and drops it for several', async () => {
await seed(A, 'first');
const one = await publishWith([A]);
expect((one.res.data as { search?: unknown }).search).toEqual({
id: A,
closed: true,
prefill: 'applied',
});
await seed(B, 'second');
const many = await publishWith([A, B]);
expect(many.res.data).not.toHaveProperty('search');
});
});

describe('runPublish — the public preview (--excerpt)', () => {
/** A stub server that also captures the parsed request body. */
function bodyServer(): { fetch: typeof fetch; body: () => Record<string, unknown> | undefined } {
Expand Down
Loading