Skip to content
Draft
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
138 changes: 138 additions & 0 deletions loopx/control_plane/work_items/personal_follow_through/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Personal follow-through: experimental TypeScript CLI

This opt-in source-checkout workflow reads one selected Lark conversation,
proposes personal commitments, applies individually reviewed changes to canonical
User Todos, and reads those Todos back. It is a partial implementation of
[the proposed personal follow-through RFC](https://github.com/loopx-project/loopx/pull/5384),
not the completed desktop M1 journey. Nothing is enabled by installation.

## Ownership and supported boundary

The workflow composes existing work-item and canonical coordination owners.
The bundled optional provider is `loopx-lark`; its read adapter lives beside the
existing Lark extension. There is no new capability, parallel Todo database,
background scheduler, or Python bridge. The CLI and domain logic run on Node.js;
source access invokes the installed `lark-cli` executable without a shell.

This initial admission profile requires an **existing** private canonical local
store and a project-local object registry (`schema_version: "0.1"`) with:

- an absolute `common_runtime_root` matching the configured runtime;
- one matching Goal with an exact `ginst_` instance identity;
- explicit `activation_state: "active"`, or the existing activation object with
explicit active state;
- no registered Agents in Goal, coordination, or spawn-policy registration.

The registry, profile, and review files must be owner-only regular files (0600),
and the runtime must be an owner-only directory (0700). The CLI refuses missing
stores, strict registry envelopes, unstamped Goals, shared/registered-Agent Goals,
and unsupported provider configurations. Do not rewrite an active registry to
bypass these restrictions. Extending registry admission belongs to the existing
registry owner and needs parity validation first. PostgreSQL is not qualified.

The Lark owner id is an operator-supplied open id, not an identity inferred from a
display name. Verify it in the selected tenant before enabling the profile. Only
bot-visible text messages are supported; attachments/cards require manual review.
A window is bounded to seven days, four pages, and 200 messages. This does not
provide full-account or private-message coverage.

## Run from a source checkout

Use Node.js satisfying the repository engine requirement and `npm ci`. Configure
an existing authenticated `lark-cli` profile separately. Keep the model API key in
an environment variable; do not put it in the profile or source tree. The endpoint
must support the JSON chat-completions request used by `model.ts`.

Create a private profile outside the source tree; substitute actual identifiers,
paths and endpoint. The following values are placeholders, not working credentials:

```json
{
"schema_version": "personal_follow_through_config_v0",
"enabled": false,
"runtime_root": "/absolute/private/runtime",
"registry_path": "/absolute/private/registry.json",
"goal_id": "personal-work",
"goal_instance_id": "ginst_00000000000000000000000000000001",
"owner_id": "ou_verified_owner",
"binding": "selected-conversation",
"chat_id": "oc_selected_conversation",
"lark_profile": "personal",
"model": {
"endpoint": "https://model.example.invalid/v1/chat/completions",
"name": "configured-model",
"key_env": "PERSONAL_MODEL_KEY"
}
}
```

Set `enabled` to true only after checking the selected source, owner, Goal and
model endpoint. `--allow-model` explicitly allows sending the captured text and
open User Todo context to that configured endpoint. Review files contain private
source text. Keep them private and delete them when no longer needed.

```sh
npm run personal-follow-through -- prepare --config "$PROFILE" \
--start "$START_ISO" --end "$END_ISO" --allow-model --output "$REVIEW"
npm run personal-follow-through -- inspect --config "$PROFILE" \
--packet "$REVIEW" --index 0
npm run personal-follow-through -- apply --config "$PROFILE" \
--packet "$REVIEW" --index 0 --approve-digest "$REVIEWED_DIGEST"
npm run personal-follow-through -- brief --config "$PROFILE"
```

Read the complete inspected packet before supplying its digest. This digest binds
an explicit CLI operation; it is not a signed approval token or protection against
other processes already running as the OS owner. The CLI creates review files
exclusively and refuses to overwrite an existing path.

After one item changes the canonical revision, refresh the next item, inspect its
new packet, and approve its new digest. Refresh rereads the source and current Todo
state without calling the model again:

```sh
npm run personal-follow-through -- refresh --config "$PROFILE" \
--packet "$REVIEW" --index 1 --output "$NEXT_REVIEW"
npm run personal-follow-through -- inspect --config "$PROFILE" --packet "$NEXT_REVIEW"
npm run personal-follow-through -- apply --config "$PROFILE" \
--packet "$NEXT_REVIEW" --approve-digest "$NEW_REVIEWED_DIGEST"
```

Exact retries use canonical receipts. Changed source text or registry/configuration
invalidates old packets. Re-prepare after those changes; review the new result.
Edits to the same source-id set cannot silently create a second Todo. Semantic
matching across different source-id sets still depends on model proposals and
owner review. Amendments preserve omitted deadline fields; explicit null means a
reviewed deadline removal. Deadline metadata is in the Todo note, with append-only
correction entries; it does not install reminders or populate a scheduler field.

`brief` reads persisted User Todos and labels source freshness `not_checked`.
The CLI cannot mark work complete, send a message, or execute a delegated task.
Set `enabled: false` to disable provider/model access and further mutations. Prior
Todos remain in the canonical store. Changes are checked between external calls
and before commits; already-sent network requests cannot be recalled. Registry
witnesses reuse the existing consistency check, not a distributed transaction.

## Validation and remaining work

```sh
node --no-warnings --experimental-sqlite --experimental-strip-types \
--test tests/control_plane_ts/personal_follow_through.test.ts
```

The process-level test invokes the real Node CLI, real HTTP transport, and a
real disposable FileAuthorityStore. Its PATH contains a scripted Lark executable;
the model server is scripted too. It covers review, create, replay, multi-item
refresh, source withdrawal, deadline correction, disabling, and registry rejection.
This proves the local process/storage journey without Python. It does **not** prove
live Lark payload compatibility, real-model extraction quality, or installed UI.

Desktop settings, persistent ChatActionStore proposal lifecycle, dismiss/manual
correction UX, desktop/private return delivery, live account qualification and
semantic evaluation remain open in the RFC. Review files here are transient CLI
handoff artifacts; they are not a second durable proposal service. The existing
desktop/Lark Python owners are unchanged and have not acquired this workflow.
The next integration owner is personal-workspace/work-items with the Lark extension;
that slice must adopt the accepted typed conversation contract and validate the
packaged desktop journey. No M1 completion or shipping claim follows from these
CLI tests.
70 changes: 70 additions & 0 deletions loopx/control_plane/work_items/personal_follow_through/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {parseArgs} from 'node:util';
import {open} from 'node:fs/promises';
import {pathToFileURL} from 'node:url';
import {captureLark} from '../../../extensions/lark/personal_follow_through.ts';
import {FollowThroughError, buildReview, digest, object, text} from './contract.ts';
import {loadConfig, profileCurrent, readPrivateJson, readTodos, applyReview, refreshReview} from './service.ts';
import {propose} from './model.ts';

async function savePrivate(path: string, data: unknown) {
// Exclusive creation: never overwrite another review or follow a symlink.
const handle = await open(path, 'wx', 0o600);
try {await handle.writeFile(`${JSON.stringify(data, null, 2)}\n`); await handle.sync();}
finally {await handle.close();}
}
export async function main(args: string[]): Promise<unknown> {
const {values, positionals} = parseArgs({args, allowPositionals: true, options: {
config: {type: 'string'}, start: {type: 'string'}, end: {type: 'string'}, output: {type: 'string'},
packet: {type: 'string'}, index: {type: 'string'}, 'approve-digest': {type: 'string'}, 'allow-model': {type: 'boolean'},
}});
if (positionals.length !== 1 || !['prepare', 'inspect', 'refresh', 'apply', 'brief'].includes(positionals[0])) throw new FollowThroughError('usage_prepare_inspect_refresh_apply_brief_with_config');
const configPath = text(values.config, 'config_path');
const c = await loadConfig(configPath);
if (!c.enabled) return {status: 'disabled'};
if (positionals[0] === 'brief') {
const head = await readTodos(c);
return {status: 'loaded', provider_revision: head.revision, source_freshness: 'not_checked',
todos: head.todos.filter(t => t.role === 'user').map(t => ({todo_id: t.todo_id, text: t.text, status: t.status, note: t.note ?? null}))};
}
if (positionals[0] === 'inspect' || positionals[0] === 'apply' || positionals[0] === 'refresh') {
const input = object(await readPrivateJson(text(values.packet, 'packet_path')), 'packet');
let packet = input;
if (input.schema_version === 'personal_follow_through_batch_v0') {
if (!Array.isArray(input.packets)) throw new FollowThroughError('invalid_batch');
const selected = values.index ?? (input.packets.length === 1 ? '0' : '');
if (!/^\d+$/.test(selected) || Number(selected) >= input.packets.length) throw new FollowThroughError('select_packet_index');
packet = object(input.packets[Number(selected)], 'packet');
}
if (positionals[0] === 'inspect') return {status: 'review_ready', digest: digest(packet), packet};
if (positionals[0] === 'refresh') {
const refreshed = await refreshReview(configPath, packet);
await savePrivate(text(values.output, 'output_path'), refreshed);
return {status: 'review_ready', digest: digest(refreshed), packet: refreshed};
}
const approval = text(values['approve-digest'], 'approval_digest', 64);
if (digest(packet) !== approval) throw new FollowThroughError('review_identity_changed');
return applyReview(configPath, packet, approval);
}
if (!values['allow-model']) throw new FollowThroughError('prepare_requires_explicit_allow_model');
const output = text(values.output, 'output_path');
const head = await readTodos(c);
const source = await captureLark(c, text(values.start, 'start'), text(values.end, 'end'), () => profileCurrent(configPath, c, head.registry_digest));
if (!await profileCurrent(configPath, c, head.registry_digest)) throw new FollowThroughError('profile_changed');
const candidates = await propose(c, source, head.todos);
const packets = candidates.map(v => buildReview(c, source, v, head));
if (new Set(packets.map(p => p.operation_id)).size !== packets.length) throw new FollowThroughError('ambiguous_multiple_commitments');
if (!await profileCurrent(configPath, c, head.registry_digest)) throw new FollowThroughError('profile_changed');
// One packet per review; mutating canonical state invalidates later stale packets.
// Refresh remaining items after each apply without another model call; review the new digest.
await savePrivate(output, {schema_version: 'personal_follow_through_batch_v0', packets});
return {status: 'prepared', count: packets.length, digests: packets.map(digest),
next: 'Use inspect --packet FILE --index N, then apply --packet FILE --index N --approve-digest DIGEST. Use refresh for remaining items after each mutation and review the new digest; no extra model call.'};
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const result = object(await main(process.argv.slice(2)), 'result');
process.stdout.write(`${JSON.stringify(result)}\n`);
if (!['disabled', 'loaded', 'prepared', 'review_ready', 'applied', 'replayed', 'recovered', 'no_change'].includes(String(result.status))) process.exitCode = 1;
}
catch (error) {process.stdout.write(`${JSON.stringify({status: 'failed', error: error instanceof FollowThroughError ? error.code : 'unexpected_failure'})}\n`); process.exitCode = 1;}
}
Loading
Loading