Merge gitea/main into the Motion branch
Motion was written against a base five commits behind main, so the integration is the interesting part of this commit: - The migration is renumbered 0014 -> 0015. Main shipped 0014_piggy_conversations, and two migrations sharing an index is a journal that applies one of them. - The seed-idempotency gate keeps main's all-tables diff rather than the motion_templates counter this branch added; the general check subsumes the specific one. - Nav gains a Motion group alongside main's new Workspace group, and Piggy keeps the mark main gave it. - Stat keeps main's container-scaled figure, which already carries the min-w-0 this branch added for the same reason. - Piggy's page labels keep main's refusal wording for the four pages with no tool of their own, and gain the three Motion routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
-- Generated by drizzle-kit, then trimmed. Three statements it emitted are not
|
||||
-- here, and deleting them is the point rather than an oversight.
|
||||
--
|
||||
-- 0005, 0012 and 0013 were hand-written and left no snapshot, so drizzle
|
||||
-- diffed against 0011 and re-proposed their work: `ALTER TYPE pig_team_role
|
||||
-- ADD VALUE 'viewer'` (0012, and without the IF NOT EXISTS that made that one
|
||||
-- safe) and a drop-and-add of the learn_resources provider CHECK (0013). On
|
||||
-- any database those migrations have already touched, the first fails outright
|
||||
-- and the second is a needless drop of a live constraint. The 0014 snapshot
|
||||
-- beside this file is kept in full, because it describes the schema as it
|
||||
-- actually is — which is what re-synchronises the snapshot chain with the
|
||||
-- hand-written migrations for whoever generates 0015.
|
||||
--
|
||||
-- Everything below is additive: two new tables and one new nullable column.
|
||||
-- Nothing here reads or rewrites an existing row, so it cannot fail on data.
|
||||
CREATE TABLE "piggy_conversations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"model" text,
|
||||
"mode" text,
|
||||
"read_capability" text DEFAULT 'book:read' NOT NULL,
|
||||
"context" jsonb,
|
||||
"last_message_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "piggy_conversations_mode_check" CHECK ("piggy_conversations"."mode" IS NULL OR "piggy_conversations"."mode" IN ('read_only', 'confirm', 'auto')),
|
||||
CONSTRAINT "piggy_conversations_read_capability_check" CHECK ("piggy_conversations"."read_capability" IN ('book:read', 'economics:read', 'team:read')),
|
||||
CONSTRAINT "piggy_conversations_title_check" CHECK (length("piggy_conversations"."title") > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "piggy_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"conversation_id" uuid NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"content" text DEFAULT '' NOT NULL,
|
||||
"reasoning" text,
|
||||
"model" text,
|
||||
"mode" text,
|
||||
"input_tokens" integer,
|
||||
"output_tokens" integer,
|
||||
"cost_micro_cents" integer,
|
||||
"finish_reason" text,
|
||||
"tool_call_id" text,
|
||||
"tool_name" text,
|
||||
"tool_arguments" jsonb,
|
||||
"tool_result" jsonb,
|
||||
"tool_ok" boolean,
|
||||
"approval_id" text,
|
||||
"approval_change" jsonb,
|
||||
"approval_decision" text,
|
||||
"approval_decided_at" timestamp with time zone,
|
||||
"error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "piggy_messages_role_check" CHECK ("piggy_messages"."role" IN ('user', 'assistant', 'tool')),
|
||||
CONSTRAINT "piggy_messages_mode_check" CHECK ("piggy_messages"."mode" IS NULL OR "piggy_messages"."mode" IN ('read_only', 'confirm', 'auto')),
|
||||
CONSTRAINT "piggy_messages_approval_decision_check" CHECK ("piggy_messages"."approval_decision" IS NULL OR "piggy_messages"."approval_decision" IN ('apply', 'reject')),
|
||||
CONSTRAINT "piggy_messages_seq_check" CHECK ("piggy_messages"."seq" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD COLUMN "piggy_conversation_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "piggy_conversations" ADD CONSTRAINT "piggy_conversations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "piggy_messages" ADD CONSTRAINT "piggy_messages_conversation_id_piggy_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."piggy_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD CONSTRAINT "agent_runs_piggy_conversation_id_piggy_conversations_id_fk" FOREIGN KEY ("piggy_conversation_id") REFERENCES "public"."piggy_conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "piggy_conversations_user_recent_idx" ON "piggy_conversations" USING btree ("user_id","last_message_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "piggy_messages_conversation_seq_key" ON "piggy_messages" USING btree ("conversation_id","seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_runs_piggy_conversation_idx" ON "agent_runs" USING btree ("piggy_conversation_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,14 @@
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1786800000000,
|
||||
"tag": "0014_motion",
|
||||
"tag": "0014_piggy_conversations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1786900000000,
|
||||
"tag": "0015_motion",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,8 +17,15 @@
|
||||
*
|
||||
* 3. **Every outward action is idempotent.** `agentActions` carries a unique
|
||||
* idempotency key, so a retried task cannot send the same message twice.
|
||||
*
|
||||
* 4. **The conversation is ours, not the harness's.** `piggyConversations` and
|
||||
* `piggyMessages` hold Piggy's transcripts, including the tools it ran and
|
||||
* the approvals it was given. The long note above those tables explains why
|
||||
* the agent harness's own session store is deliberately unused.
|
||||
*/
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
@@ -30,6 +37,13 @@ import {
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
PIGGY_MODES,
|
||||
READ_CAPABILITIES,
|
||||
type PiggyApprovalDecision,
|
||||
type PiggyChatContext,
|
||||
type PiggyProposedChange,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
agentTaskKindEnum,
|
||||
agentTaskOutcomeEnum,
|
||||
@@ -39,6 +53,14 @@ import {
|
||||
import { accounts, contacts } from './crm';
|
||||
import { users } from './identity';
|
||||
|
||||
/**
|
||||
* Render a value set as a SQL `IN` list from the ontology constant. Same
|
||||
* reasoning as `learn.ts`: a vocabulary typed out again in a CHECK is a
|
||||
* vocabulary that drifts from the one the application validates against.
|
||||
*/
|
||||
const inList = (values: readonly string[]) =>
|
||||
sql.raw(values.map((value) => `'${value}'`).join(', '));
|
||||
|
||||
/**
|
||||
* The work queue. Leased rather than locked: a worker claims a row by stamping
|
||||
* `leasedUntil` into the future, and a crashed worker's rows become claimable
|
||||
@@ -150,6 +172,210 @@ export const facts = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* What a transcript row can be.
|
||||
*
|
||||
* `tool` is a first-class role rather than an assistant message with metadata
|
||||
* hung off it, because the renderer draws it as its own card and because a
|
||||
* turn can call several tools between two sentences.
|
||||
*/
|
||||
export const PIGGY_MESSAGE_ROLES = ['user', 'assistant', 'tool'] as const;
|
||||
export type PiggyMessageRole = (typeof PIGGY_MESSAGE_ROLES)[number];
|
||||
|
||||
/**
|
||||
* `PiggyApprovalDecision` as a value.
|
||||
*
|
||||
* @pig/core states it as a type union, and a CHECK constraint needs values.
|
||||
* `satisfies` stops this list drifting into something the protocol does not
|
||||
* admit. The other direction — a decision added to the union and not here —
|
||||
* the compiler cannot see, so it fails as a rejected insert the first time
|
||||
* that decision is recorded, which is loud rather than silent.
|
||||
*/
|
||||
const PIGGY_APPROVAL_DECISIONS = [
|
||||
'apply',
|
||||
'reject',
|
||||
] as const satisfies readonly PiggyApprovalDecision[];
|
||||
|
||||
/**
|
||||
* Piggy's conversations, and why they are stored here rather than by the
|
||||
* harness.
|
||||
*
|
||||
* Prime Agent ships a `SessionManager` with file-backed sessions, and PIG
|
||||
* deliberately does not use it for storage. Three reasons, all of which look
|
||||
* like an unnecessary complication until the fourth week in production:
|
||||
*
|
||||
* - PIG's source of truth is Postgres. A transcript is a record of what the
|
||||
* company was told about its own book; it belongs with the book.
|
||||
* - A session must be per-user and stay that way. Ownership here is a column
|
||||
* and a predicate on every query, not a filename someone could guess.
|
||||
* - The chat server runs in a container and will one day run in two. A file
|
||||
* under the agent directory survives neither a restart nor a second
|
||||
* replica, and the failure is silent: a user's history simply empties.
|
||||
*
|
||||
* So the harness is handed `SessionManager.inMemory()` for the length of one
|
||||
* turn and the history is rehydrated from these tables. If you are here to
|
||||
* "simplify" that by pointing the harness at its own store, this is the note
|
||||
* saying it was considered and refused.
|
||||
*
|
||||
* The transcript keeps evidence, not just prose. Reopening a conversation has
|
||||
* to show what Piggy DID — which tool ran, with what arguments, what came
|
||||
* back, what was proposed for approval and how the user answered — because the
|
||||
* product's whole claim is that an agent writing to a CRM is auditable. A
|
||||
* transcript that reloads as a wall of assistant text quietly withdraws it.
|
||||
* The authoritative record of a mutation is still the `activities` row written
|
||||
* by `executeMutation`; this is the record of the conversation that led to it.
|
||||
*/
|
||||
export const piggyConversations = pgTable(
|
||||
'piggy_conversations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
/**
|
||||
* The owner, and the only person who may read it. `cascade` rather than
|
||||
* the `set null` used for the audit-ish references above: an ownerless
|
||||
* transcript is not a historical record anyone can act on, it is a pile of
|
||||
* somebody's private questions with no one left to answer for them.
|
||||
*/
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Derived from the first user message, renameable. Never empty. */
|
||||
title: text('title').notNull(),
|
||||
|
||||
/**
|
||||
* The last turn's model and mode, so reopening a conversation restores the
|
||||
* picker rather than silently answering the next question on a different
|
||||
* model from the one the rest of the thread was answered by.
|
||||
*/
|
||||
model: text('model'),
|
||||
mode: text('mode', { enum: PIGGY_MODES }),
|
||||
|
||||
/**
|
||||
* The capability the transcript itself requires, raised by the relay to
|
||||
* whatever the turn's context needed.
|
||||
*
|
||||
* Without this, a person demoted out of `economics:read` keeps a doorway
|
||||
* to supplier cost and break-even: they cannot ask for margin any more,
|
||||
* but yesterday's answer is still sitting in their history. The floor in
|
||||
* READ_RULES cannot see it, because a path-keyed table cannot know what a
|
||||
* particular conversation was told.
|
||||
*/
|
||||
readCapability: text('read_capability', { enum: READ_CAPABILITIES })
|
||||
.notNull()
|
||||
.default('book:read'),
|
||||
|
||||
/** Where the conversation was opened from, for the sidebar's subtitle. */
|
||||
context: jsonb('context').$type<PiggyChatContext>(),
|
||||
|
||||
/**
|
||||
* When the transcript last gained a message — the sidebar's sort key.
|
||||
* Separate from `updatedAt` on purpose: renaming a conversation is not
|
||||
* activity, and should not shuffle it to the top of somebody's history.
|
||||
*/
|
||||
lastMessageAt: timestamp('last_message_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
/** The only listing query there is: my conversations, most recent first. */
|
||||
index('piggy_conversations_user_recent_idx').on(t.userId, t.lastMessageAt.desc()),
|
||||
check('piggy_conversations_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
|
||||
check(
|
||||
'piggy_conversations_read_capability_check',
|
||||
sql`${t.readCapability} IN (${inList(READ_CAPABILITIES)})`,
|
||||
),
|
||||
check('piggy_conversations_title_check', sql`length(${t.title}) > 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per entry the transcript renders: a user message, an assistant
|
||||
* message, or a tool call together with its approval record.
|
||||
*
|
||||
* The tool call and its approval share a row deliberately. They are one event
|
||||
* to a reader — "Piggy offered to log a call, you approved it, it wrote" — and
|
||||
* splitting them across rows means a reload can show the offer without the
|
||||
* answer, which is the one rendering that misleads.
|
||||
*
|
||||
* Persistence is best-effort with respect to the mutation: a crash between the
|
||||
* write and this row loses the transcript entry, never the audit trail.
|
||||
*/
|
||||
export const piggyMessages = pgTable(
|
||||
'piggy_messages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.notNull()
|
||||
.references(() => piggyConversations.id, { onDelete: 'cascade' }),
|
||||
|
||||
/**
|
||||
* Position in the transcript. `created_at` is not enough: a tool call and
|
||||
* its result are written in the same millisecond, and a Date carries no
|
||||
* more precision than that, so ordering on the timestamp alone renders
|
||||
* them in an arbitrary order about a third of the time.
|
||||
*/
|
||||
seq: integer('seq').notNull(),
|
||||
|
||||
role: text('role', { enum: PIGGY_MESSAGE_ROLES }).notNull(),
|
||||
/** Empty for a tool row, whose content is the call and its result. */
|
||||
content: text('content').notNull().default(''),
|
||||
/** Kept for reasoning models: what it thought, as the transcript showed it. */
|
||||
reasoning: text('reasoning'),
|
||||
|
||||
/** Which model answered. It varies per turn now that the user chooses. */
|
||||
model: text('model'),
|
||||
/** The mode the turn ran in — an approval card means nothing without it. */
|
||||
mode: text('mode', { enum: PIGGY_MODES }),
|
||||
|
||||
inputTokens: integer('input_tokens'),
|
||||
outputTokens: integer('output_tokens'),
|
||||
/** Micro-cents, matching `agent_runs.cost_micro_cents`. */
|
||||
costMicroCents: integer('cost_micro_cents'),
|
||||
/** `length` when the answer was cut short by the token budget. */
|
||||
finishReason: text('finish_reason'),
|
||||
|
||||
toolCallId: text('tool_call_id'),
|
||||
toolName: text('tool_name'),
|
||||
toolArguments: jsonb('tool_arguments').$type<Record<string, unknown>>(),
|
||||
toolResult: jsonb('tool_result').$type<Record<string, unknown>>(),
|
||||
toolOk: boolean('tool_ok'),
|
||||
|
||||
/**
|
||||
* The approval record, stored as it was shown rather than as a pointer to
|
||||
* the record it touched. The summary and field values a user actually
|
||||
* agreed to are the evidence; re-deriving them from the row later would
|
||||
* show what the record says now, which is a different claim.
|
||||
*/
|
||||
approvalId: text('approval_id'),
|
||||
approvalChange: jsonb('approval_change').$type<PiggyProposedChange>(),
|
||||
/** Null means never answered — a turn that timed out or was abandoned. */
|
||||
approvalDecision: text('approval_decision', { enum: PIGGY_APPROVAL_DECISIONS }),
|
||||
approvalDecidedAt: timestamp('approval_decided_at', { withTimezone: true }),
|
||||
|
||||
/** A failed turn or a failed tool. Shown, not swallowed. */
|
||||
error: text('error'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
/**
|
||||
* Ordering and the append guard in one index. A conversation is always
|
||||
* read whole and in order, so this is also the only index it needs — its
|
||||
* leading column serves the `conversation_id` lookups on its own.
|
||||
*/
|
||||
uniqueIndex('piggy_messages_conversation_seq_key').on(t.conversationId, t.seq),
|
||||
check('piggy_messages_role_check', sql`${t.role} IN (${inList(PIGGY_MESSAGE_ROLES)})`),
|
||||
check('piggy_messages_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
|
||||
check(
|
||||
'piggy_messages_approval_decision_check',
|
||||
sql`${t.approvalDecision} IS NULL OR ${t.approvalDecision} IN (${inList(PIGGY_APPROVAL_DECISIONS)})`,
|
||||
),
|
||||
check('piggy_messages_seq_check', sql`${t.seq} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/** One execution of the agent, for cost accounting and debugging. */
|
||||
export const agentRuns = pgTable(
|
||||
'agent_runs',
|
||||
@@ -159,6 +385,18 @@ export const agentRuns = pgTable(
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
|
||||
/**
|
||||
* The Piggy conversation this run answered, when it was one — which makes
|
||||
* "what has this thread cost?" a sum over this column rather than a join
|
||||
* through the transcript. `set null` because the spend happened whatever
|
||||
* became of the conversation: deleting a thread must not delete the record
|
||||
* of the credit it burned.
|
||||
*/
|
||||
piggyConversationId: uuid('piggy_conversation_id').references(
|
||||
() => piggyConversations.id,
|
||||
{ onDelete: 'set null' },
|
||||
),
|
||||
|
||||
/** Which agent — 'piggy', or a user's own connected client. */
|
||||
agent: text('agent').notNull().default('piggy'),
|
||||
/** The person on whose behalf this ran. */
|
||||
@@ -183,6 +421,8 @@ export const agentRuns = pgTable(
|
||||
(t) => [
|
||||
index('agent_runs_task_idx').on(t.agentTaskId),
|
||||
index('agent_runs_principal_idx').on(t.principalUserId),
|
||||
/** Per-conversation spend is one indexed scan, not a table sweep. */
|
||||
index('agent_runs_piggy_conversation_idx').on(t.piggyConversationId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -227,3 +467,7 @@ export type NewAgentTask = typeof agentTasks.$inferInsert;
|
||||
export type Fact = typeof facts.$inferSelect;
|
||||
export type NewFact = typeof facts.$inferInsert;
|
||||
export type AgentRun = typeof agentRuns.$inferSelect;
|
||||
export type PiggyConversation = typeof piggyConversations.$inferSelect;
|
||||
export type NewPiggyConversation = typeof piggyConversations.$inferInsert;
|
||||
export type PiggyMessage = typeof piggyMessages.$inferSelect;
|
||||
export type NewPiggyMessage = typeof piggyMessages.$inferInsert;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
*
|
||||
* The FK cycle between `motion_templates.origin_artifact_id` and
|
||||
* `engagement_artifacts.template_id` is intentional — it is the loop — and it
|
||||
* is why migration `0014_motion.sql` is hand-written, adding one of the two
|
||||
* is why migration `0015_motion.sql` is hand-written, adding one of the two
|
||||
* constraints in a separate `ALTER TABLE` after both tables exist.
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
@@ -46,8 +46,9 @@
|
||||
* always run in; sections depend on ids the earlier ones return.
|
||||
*/
|
||||
import { quarterBoundsFor } from '@pig/core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { createDatabase, type Database } from '../../client';
|
||||
import { users } from '../../schema/index';
|
||||
import { capacityCommitments, demandDeals, supplyDeals, users } from '../../schema/index';
|
||||
import { seedSupplyActivities } from './activities';
|
||||
import { seedFacts } from './agent';
|
||||
import { seedCalendar } from './calendar';
|
||||
@@ -182,14 +183,32 @@ export async function seedDemo(context: DemoContext): Promise<void> {
|
||||
|
||||
const facts = await seedFacts(context);
|
||||
|
||||
console.log(' 5 capacity commitments (4 live, 1 lapsed), with sites, MSAs and negotiated SLAs');
|
||||
/*
|
||||
* Counted, not asserted. These lines were hardcoded when the book was split
|
||||
* into modules, and they drifted the moment the seed grew: the summary
|
||||
* claimed 12 demand deals and 5 commitments against a database holding 13
|
||||
* and 6. A seed that misreports what it wrote teaches an operator to
|
||||
* distrust the only feedback the command gives them.
|
||||
*/
|
||||
const [written] = await context.db
|
||||
.select({
|
||||
commitments: sql<number>`(select count(*)::int from ${capacityCommitments})`,
|
||||
demandDeals: sql<number>`(select count(*)::int from ${demandDeals})`,
|
||||
supplyDeals: sql<number>`(select count(*)::int from ${supplyDeals})`,
|
||||
demandStages: sql<number>`(select count(distinct stage)::int from ${demandDeals})`,
|
||||
})
|
||||
.from(sql`(select 1) as one`);
|
||||
const book = written ?? { commitments: 0, demandDeals: 0, supplyDeals: 0, demandStages: 0 };
|
||||
|
||||
console.log(` ${book.commitments} capacity commitments, with sites, MSAs and negotiated SLAs`);
|
||||
console.log(` ${facts.total} agent-derived facts (${facts.added} new) — 2 applied, 4 awaiting review`);
|
||||
console.log(
|
||||
` ${facts.tasks} agent tasks and ${facts.runs} Piggy runs, ${facts.actions} idempotency-keyed actions, ` +
|
||||
`${(facts.costMicroCents / 1_000_000).toFixed(4)} cents of model spend`,
|
||||
);
|
||||
console.log(
|
||||
' 12 demand deals across all ten stages — 2 won, 1 lost, 1 expansion off a closed parent — and 8 supply deals',
|
||||
` ${book.demandDeals} demand deals across ${book.demandStages} stages — including a won parent with its ` +
|
||||
`expansion child, and a loss with a reason — and ${book.supplyDeals} supply deals`,
|
||||
);
|
||||
console.log(' Allocations including one unconverted hold and internal research burn');
|
||||
console.log(
|
||||
|
||||
@@ -226,9 +226,14 @@ export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [
|
||||
},
|
||||
{
|
||||
slug: 'piggy-and-its-boundary',
|
||||
title: 'Piggy, and what it will not do',
|
||||
title: 'Piggy, and where it stops',
|
||||
// This said Piggy had "no ability to write CRM records" — the headline
|
||||
// capability of the release it ships beside, denied on the one track a
|
||||
// public share code opens, two clicks from a front door reading "Piggy can
|
||||
// change it, with your approval." The boundary is real and still worth
|
||||
// stating; it is approval, not incapability.
|
||||
summary:
|
||||
'The docked agent reads through scoped, page-specific PIG tools — and has no shell, no filesystem, and no ability to write CRM records.',
|
||||
'The docked agent reads through scoped, page-specific PIG tools, and proposes any change as a card that only a person can apply — no shell, no filesystem, and nothing written without a human pressing Apply.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 28,
|
||||
|
||||
Reference in New Issue
Block a user