@aragon-agent/cli 0.6.45 → 0.6.46

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.
package/README.md CHANGED
@@ -3013,6 +3013,57 @@ declares no placeholder, the arguments are appended under an `## Arguments`
3013
3013
  heading instead. Where a skill's name collides with a built-in command, the
3014
3014
  **built-in wins** and the skill is reachable as `/skill:<name>`.
3015
3015
 
3016
+ ### Invocation cards
3017
+
3018
+ A command that starts a task by submitting a long prompt on your behalf does not
3019
+ print that prompt. `/pdf-forms`, `/skill:<name>`, `/team <brief>`, `/todo continue`
3020
+ and `/gov resume` each leave one **card** in the transcript instead: what you
3021
+ started, a few facts, and your own words.
3022
+
3023
+ ```
3024
+ > skill . pdf-forms v1.2.0 . user . 31 KB . 4 files
3025
+ Fill the W-9 form in ./tax/w9.pdf with the values from data.json
3026
+
3027
+ > team 4 subagents . read-only
3028
+ Audit the auth module for session fixation and add regression tests
3029
+ Asked for 12; team.maxSubagents is 8.
3030
+
3031
+ > todo . continue 3 of 7 steps left
3032
+ ```
3033
+
3034
+ (`.` is `·` on a Unicode terminal.) The first row names the command and its
3035
+ facts and is shortened to the width of the window - facts go first, then the name.
3036
+ Your own words follow, capped at 8 lines / 480 characters with an honest
3037
+ `... +N chars not shown` tail. A line below it appears only when the command
3038
+ changed what you asked for (a fan-out above `team.maxSubagents`). `Ctrl+I` shows a
3039
+ `ctrl+i` fact and no input row: the sentence it sends the model is not something
3040
+ you said.
3041
+
3042
+ What changes and what does not:
3043
+
3044
+ - **The model receives exactly the bytes it did before.** Only the transcript
3045
+ entry is different; the skill body, the team directive and the continuation text
3046
+ are untouched.
3047
+ - The `Command:` echo and the old `Skill "x" loaded (...)` / `Forcing a fan-out...`
3048
+ notices are folded into the card, so one act is stated once.
3049
+ - Up-arrow history and the header task label hold the command you typed
3050
+ (`/skill:pdf-forms a.pdf`), not the prompt behind it. Canned turns the commands
3051
+ submit for you (`/team`, `/todo continue`, `/gov resume`) still stay out of
3052
+ history and do not trigger a task label, exactly as before.
3053
+ - A skill run now adds one history entry instead of two to the hint-fade counter.
3054
+ - Secrets in your arguments are redacted **before** anything is cut, so a key that
3055
+ straddles a cut cannot leave a readable prefix in the transcript, the session
3056
+ file or the exit replay.
3057
+ - The exit replay prints the command line plus one `[skill pdf-forms v1.2.0 - ...]`
3058
+ line. A very long brief is capped at 2000 characters there; the full text is in
3059
+ the up-arrow history and in the conversation itself.
3060
+ - Sessions written before cards existed load unchanged; a damaged card in a session
3061
+ file degrades to the plain command line instead of failing the load.
3062
+
3063
+ `ARAGON_NO_INVOCATION_CARDS=1` restores the previous behaviour completely (echo,
3064
+ notices and the full prompt in the transcript). It is an emergency switch, not a
3065
+ setting.
3066
+
3016
3067
  ### Where skills come from
3017
3068
 
3018
3069
  Four scopes, later ones overriding earlier ones by name:
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Invocation cards (tui-invocation-cards): what a transcript shows INSTEAD of the
3
+ * system prompt a command submitted on the user's behalf.
4
+ *
5
+ * Pure and total: no IO, no React, no Ink, nothing but `import type`. ASCII ONLY -
6
+ * `src/agent/**` new files sit inside the `glyphs.test.ts` scan scope.
7
+ *
8
+ * ONE CONSTRUCTOR (I-3). `buildInvocationCard` is the only function that turns raw
9
+ * command-layer material into a card, `App.submitMessage` is its only caller, and
10
+ * `sanitizeInvocationCard` (the session-load path) shares its normaliser so a card
11
+ * written today and a card read back tomorrow cannot drift apart.
12
+ *
13
+ * ORDER IS REDACT -> NORMALISE -> LIMIT, and it is the load-bearing part (I-6). Cut
14
+ * first and a secret that straddles the cut leaves a prefix shorter than the
15
+ * redaction patterns' minimum length: the half-key sails through into the
16
+ * transcript, the session file and the exit replay.
17
+ */
18
+ export type InvocationVariant = 'skill' | 'team' | 'todo' | 'govern';
19
+ /** Everything one card needs; persisted, queued and rendered in this one shape. */
20
+ export interface InvocationCard {
21
+ readonly variant: InvocationVariant;
22
+ /** Skill name / 'continue' / 'resume'; '' for team. */
23
+ readonly name: string;
24
+ /** What the user typed (or its equivalent), redacted and capped; also `Entry.text`. */
25
+ readonly commandLine: string;
26
+ /** The user's own words (args / brief), redacted and trimmed; may be ''. */
27
+ readonly input: string;
28
+ /** Characters cut from `input`; 0 when nothing was cut. */
29
+ readonly inputHidden: number;
30
+ /** Fact fragments, each at most `factMaxChars`, at most `factsMax` of them. */
31
+ readonly facts: readonly string[];
32
+ /** One disclosure sentence. */
33
+ readonly note?: string;
34
+ }
35
+ /**
36
+ * The renderer, the height estimate and the loader all read THESE numbers (I-4).
37
+ * A card's height is a function of its data and these limits, so it is bounded:
38
+ * header + `inputMaxLines` + hidden tail + note.
39
+ */
40
+ export declare const INVOCATION_LIMITS: {
41
+ readonly commandLineMaxChars: 2000;
42
+ readonly inputMaxChars: 480;
43
+ readonly inputMaxLines: 8;
44
+ readonly nameMaxChars: 64;
45
+ readonly factsMax: 5;
46
+ readonly factMaxChars: 28;
47
+ readonly noteMaxChars: 140;
48
+ };
49
+ /**
50
+ * Raw material from the command layer: NOT redacted, NOT trimmed. Commands must not
51
+ * cut or scrub it themselves - both happen exactly once, in `buildInvocationCard`.
52
+ */
53
+ export interface InvocationCardInput {
54
+ variant: InvocationVariant;
55
+ name?: string;
56
+ commandLine: string;
57
+ input?: string;
58
+ facts?: readonly string[];
59
+ note?: string;
60
+ }
61
+ /**
62
+ * Total, never throws. `redact` is injected (the same closure `Command:` echo uses) so
63
+ * this module depends on nothing; the default is the identity for tests and the loader.
64
+ */
65
+ export declare function buildInvocationCard(input: InvocationCardInput, redact?: (text: string) => string): InvocationCard;
66
+ /**
67
+ * Session-load path: `null` for anything that is not a plausible card, so the caller
68
+ * drops the field and the entry degrades to a plain user message (I-7). Re-derives
69
+ * `inputHidden` from nothing - it trusts the stored count only when it is a sane integer.
70
+ */
71
+ export declare function sanitizeInvocationCard(value: unknown): InvocationCard | null;
72
+ /**
73
+ * Emergency kill switch (`ARAGON_NO_INVOCATION_CARDS=1`): every command and the App fall
74
+ * back to the pre-card behaviour. Read at call time, not import time, so a test (or a
75
+ * wrapper script) that sets the variable after load is honoured.
76
+ */
77
+ export declare function invocationCardsEnabled(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Invocation cards (tui-invocation-cards): what a transcript shows INSTEAD of the
3
+ * system prompt a command submitted on the user's behalf.
4
+ *
5
+ * Pure and total: no IO, no React, no Ink, nothing but `import type`. ASCII ONLY -
6
+ * `src/agent/**` new files sit inside the `glyphs.test.ts` scan scope.
7
+ *
8
+ * ONE CONSTRUCTOR (I-3). `buildInvocationCard` is the only function that turns raw
9
+ * command-layer material into a card, `App.submitMessage` is its only caller, and
10
+ * `sanitizeInvocationCard` (the session-load path) shares its normaliser so a card
11
+ * written today and a card read back tomorrow cannot drift apart.
12
+ *
13
+ * ORDER IS REDACT -> NORMALISE -> LIMIT, and it is the load-bearing part (I-6). Cut
14
+ * first and a secret that straddles the cut leaves a prefix shorter than the
15
+ * redaction patterns' minimum length: the half-key sails through into the
16
+ * transcript, the session file and the exit replay.
17
+ */
18
+ /**
19
+ * The renderer, the height estimate and the loader all read THESE numbers (I-4).
20
+ * A card's height is a function of its data and these limits, so it is bounded:
21
+ * header + `inputMaxLines` + hidden tail + note.
22
+ */
23
+ export const INVOCATION_LIMITS = {
24
+ commandLineMaxChars: 2000,
25
+ inputMaxChars: 480,
26
+ inputMaxLines: 8,
27
+ nameMaxChars: 64,
28
+ factsMax: 5,
29
+ factMaxChars: 28,
30
+ noteMaxChars: 140,
31
+ };
32
+ const VARIANTS = ['skill', 'team', 'todo', 'govern'];
33
+ // C0 except TAB (\x09) and LF (\x0a), then DEL and C1 (which includes the 8-bit CSI
34
+ // \x9b). Matches the range `QueueOverlay` strips, so the card is no more permissive.
35
+ // eslint-disable-next-line no-control-regex
36
+ const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
37
+ const CAP_MARK = '...';
38
+ /** Cut at a code-point boundary: a surrogate pair is never split. */
39
+ function sliceCodePoints(text, count) {
40
+ if (count <= 0)
41
+ return '';
42
+ if (text.length <= count)
43
+ return text;
44
+ let end = count;
45
+ const last = text.charCodeAt(end - 1);
46
+ // A high surrogate at the cut would orphan its low half.
47
+ if (last >= 0xd800 && last <= 0xdbff)
48
+ end -= 1;
49
+ return text.slice(0, end);
50
+ }
51
+ function countCodePoints(text) {
52
+ let count = 0;
53
+ for (const _ of text)
54
+ count += 1;
55
+ return count;
56
+ }
57
+ /** CRLF and bare CR to LF (a bare CR returns Ink's cursor to column 0), then strip controls. */
58
+ function normalizeText(text) {
59
+ return text.replace(/\r\n?/g, '\n').replace(CONTROL_CHARS, '');
60
+ }
61
+ function capCommandLine(text) {
62
+ const max = INVOCATION_LIMITS.commandLineMaxChars;
63
+ if (text.length <= max)
64
+ return text;
65
+ return sliceCodePoints(text, max - CAP_MARK.length) + CAP_MARK;
66
+ }
67
+ function capInput(text) {
68
+ const trimmed = text.trim();
69
+ if (trimmed === '')
70
+ return { input: '', hidden: 0 };
71
+ const lines = trimmed.split('\n');
72
+ let kept = lines.length > INVOCATION_LIMITS.inputMaxLines
73
+ ? lines.slice(0, INVOCATION_LIMITS.inputMaxLines).join('\n') : trimmed;
74
+ kept = sliceCodePoints(kept, INVOCATION_LIMITS.inputMaxChars).trimEnd();
75
+ return { input: kept, hidden: countCodePoints(trimmed) - countCodePoints(kept) };
76
+ }
77
+ function capSingleLine(text, max) {
78
+ const flat = text.replace(/\s+/g, ' ').trim();
79
+ if (countCodePoints(flat) <= max)
80
+ return flat;
81
+ return sliceCodePoints(flat, max - CAP_MARK.length) + CAP_MARK;
82
+ }
83
+ function capFacts(facts) {
84
+ return facts
85
+ .map((fact) => capSingleLine(fact, INVOCATION_LIMITS.factMaxChars))
86
+ .filter((fact) => fact !== '')
87
+ .slice(0, INVOCATION_LIMITS.factsMax);
88
+ }
89
+ function capNote(note) {
90
+ if (note === undefined)
91
+ return undefined;
92
+ const capped = capSingleLine(note, INVOCATION_LIMITS.noteMaxChars);
93
+ return capped === '' ? undefined : capped;
94
+ }
95
+ /** Shared by the write path and the load path so the two cannot diverge. */
96
+ function assemble(parts) {
97
+ const { input, hidden } = capInput(normalizeText(parts.input));
98
+ const note = capNote(normalizeText(parts.note ?? ''));
99
+ return {
100
+ variant: parts.variant,
101
+ name: capSingleLine(normalizeText(parts.name), INVOCATION_LIMITS.nameMaxChars),
102
+ commandLine: capCommandLine(normalizeText(parts.commandLine).trim()),
103
+ input,
104
+ inputHidden: hidden,
105
+ facts: capFacts(parts.facts.map(normalizeText)),
106
+ ...(note !== undefined ? { note } : {}),
107
+ };
108
+ }
109
+ /**
110
+ * Total, never throws. `redact` is injected (the same closure `Command:` echo uses) so
111
+ * this module depends on nothing; the default is the identity for tests and the loader.
112
+ */
113
+ export function buildInvocationCard(input, redact = (text) => text) {
114
+ const scrub = (text) => {
115
+ if (text === undefined)
116
+ return '';
117
+ try {
118
+ return redact(text);
119
+ }
120
+ catch {
121
+ return '';
122
+ }
123
+ };
124
+ return assemble({
125
+ variant: input.variant,
126
+ name: scrub(input.name),
127
+ commandLine: scrub(input.commandLine),
128
+ input: scrub(input.input),
129
+ facts: (input.facts ?? []).map((fact) => scrub(fact)),
130
+ note: input.note === undefined ? undefined : scrub(input.note),
131
+ });
132
+ }
133
+ function asString(value) {
134
+ return typeof value === 'string' ? value : null;
135
+ }
136
+ /**
137
+ * Session-load path: `null` for anything that is not a plausible card, so the caller
138
+ * drops the field and the entry degrades to a plain user message (I-7). Re-derives
139
+ * `inputHidden` from nothing - it trusts the stored count only when it is a sane integer.
140
+ */
141
+ export function sanitizeInvocationCard(value) {
142
+ if (typeof value !== 'object' || value === null)
143
+ return null;
144
+ const raw = value;
145
+ const variant = VARIANTS.find((candidate) => candidate === raw.variant);
146
+ const commandLine = asString(raw.commandLine);
147
+ if (variant === undefined || commandLine === null)
148
+ return null;
149
+ const name = asString(raw.name) ?? '';
150
+ const input = asString(raw.input) ?? '';
151
+ const facts = Array.isArray(raw.facts)
152
+ ? raw.facts.filter((fact) => typeof fact === 'string') : [];
153
+ const note = asString(raw.note) ?? undefined;
154
+ const card = assemble({ variant, name, commandLine, input, facts, note });
155
+ // A stored card that was already capped must keep its recorded `inputHidden`; the
156
+ // re-cap above can only ever add to it.
157
+ const stored = raw.inputHidden;
158
+ const storedHidden = typeof stored === 'number' && Number.isInteger(stored) && stored > 0
159
+ ? Math.min(stored, 10_000_000) : 0;
160
+ return { ...card, inputHidden: Math.max(card.inputHidden, storedHidden) };
161
+ }
162
+ /**
163
+ * Emergency kill switch (`ARAGON_NO_INVOCATION_CARDS=1`): every command and the App fall
164
+ * back to the pre-card behaviour. Read at call time, not import time, so a test (or a
165
+ * wrapper script) that sets the variable after load is honoured.
166
+ */
167
+ export function invocationCardsEnabled(env = process.env) {
168
+ return env.ARAGON_NO_INVOCATION_CARDS !== '1';
169
+ }
@@ -1,3 +1,4 @@
1
+ import type { InvocationCardInput } from './invocation-card.js';
1
2
  /** Explicit TUI task boundary; omission retains legacy programmatic behavior. */
2
3
  export interface PromptOptions {
3
4
  todoPolicy?: 'new-task' | 'continue';
@@ -5,6 +6,11 @@ export interface PromptOptions {
5
6
  /** History recording is independent of whether this message continues a plan. */
6
7
  export interface SubmitMessageOptions extends PromptOptions {
7
8
  userInitiated?: boolean;
9
+ /**
10
+ * Raw material for the transcript card. `submitMessage` builds the card exactly once
11
+ * (redact -> normalise -> limit); the payload sent to the model is unaffected.
12
+ */
13
+ invocation?: InvocationCardInput;
8
14
  }
9
15
  /** Startup outcome only; engine events still describe the result of the run. */
10
16
  export type PromptOutcome = {
@@ -1,8 +1,11 @@
1
1
  import type { Entry } from './reducer.js';
2
+ import type { InvocationCard } from './invocation-card.js';
2
3
  /** Full user text retained until Core acknowledges its opaque ID. */
3
4
  export interface PendingSteering {
4
5
  readonly queueId: string;
5
6
  readonly text: string;
7
+ /** Carried so the `queued -> user` rewrite keeps the card (tui-invocation-cards). */
8
+ readonly invocation?: InvocationCard;
6
9
  }
7
10
  /** Hide only active pending entries, using the same projection for measurement and paint. */
8
11
  export declare function selectTranscriptEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
@@ -13,7 +13,10 @@ export function reconcileSteeringReceipt(options) {
13
13
  if (!ids.size)
14
14
  return { entries: options.entries, pending: [...options.pending], changed: false };
15
15
  const entries = mergePendingEntries(options.entries, options.pending).map((entry) => entry.kind === 'queued' && ids.has(entry.queueId ?? '')
16
- ? { id: entry.id, kind: 'user', text: entry.text } : entry);
16
+ ? {
17
+ id: entry.id, kind: 'user', text: entry.text,
18
+ ...(entry.invocation ? { invocation: entry.invocation } : {}),
19
+ } : entry);
17
20
  return { entries, pending: options.pending.filter((item) => !ids.has(item.queueId)), changed: true };
18
21
  }
19
22
  /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
@@ -11,6 +11,7 @@
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
13
  import type { PendingSteering } from './queued-messages.js';
14
+ import type { InvocationCard } from './invocation-card.js';
14
15
  import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agent/core';
15
16
  import type { AgentMode } from './agent-mode.js';
16
17
  import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
@@ -47,6 +48,7 @@ export type Entry = {
47
48
  id: string;
48
49
  kind: 'user';
49
50
  text: string;
51
+ invocation?: InvocationCard;
50
52
  }
51
53
  /**
52
54
  * A message submitted while a run was in flight (tui-shift-enter-copy-queue
@@ -63,6 +65,7 @@ export type Entry = {
63
65
  kind: 'queued';
64
66
  text: string;
65
67
  queueId?: string;
68
+ invocation?: InvocationCard;
66
69
  } | {
67
70
  id: string;
68
71
  kind: 'assistant';
@@ -507,6 +510,7 @@ export declare function initialViewState(seed?: ViewStateSeed): ViewState;
507
510
  export type ViewAction = {
508
511
  type: 'submit';
509
512
  text: string;
513
+ invocation?: InvocationCard;
510
514
  }
511
515
  /**
512
516
  * `controller.steer` was accepted; append the persistent queue marker
@@ -517,6 +521,7 @@ export type ViewAction = {
517
521
  type: 'steerQueued';
518
522
  queueId: string;
519
523
  text: string;
524
+ invocation?: InvocationCard;
520
525
  } | {
521
526
  type: 'steeringAccepted';
522
527
  ids: readonly string[];
@@ -484,7 +484,10 @@ export function viewReducer(state, action) {
484
484
  switch (action.type) {
485
485
  case 'submit': {
486
486
  const { id, seq } = nextId(state);
487
- const entry = { id, kind: 'user', text: action.text };
487
+ const entry = {
488
+ id, kind: 'user', text: action.text,
489
+ ...(action.invocation ? { invocation: action.invocation } : {}),
490
+ };
488
491
  return {
489
492
  ...state,
490
493
  seq,
@@ -507,7 +510,10 @@ export function viewReducer(state, action) {
507
510
  if (state.seenSteeringIds.has(action.queueId))
508
511
  return state;
509
512
  const { id, seq } = nextId(state);
510
- const entry = { id, kind: 'queued', queueId: action.queueId, text: action.text };
513
+ const entry = {
514
+ id, kind: 'queued', queueId: action.queueId, text: action.text,
515
+ ...(action.invocation ? { invocation: action.invocation } : {}),
516
+ };
511
517
  // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
512
518
  // `errorNoticed` and `aborted` describe the run that is still in
513
519
  // flight, and clearing them here would let the next `runEnd` failure
@@ -515,7 +521,10 @@ export function viewReducer(state, action) {
515
521
  return {
516
522
  ...state, seq, ...appendEntry(state, state.entries, entry),
517
523
  seenSteeringIds: new Set([...state.seenSteeringIds, action.queueId]),
518
- pendingSteering: [...state.pendingSteering, { queueId: action.queueId, text: action.text }],
524
+ pendingSteering: [...state.pendingSteering, {
525
+ queueId: action.queueId, text: action.text,
526
+ ...(action.invocation ? { invocation: action.invocation } : {}),
527
+ }],
519
528
  };
520
529
  }
521
530
  case 'steeringAccepted': {
@@ -621,6 +621,8 @@ const COMMANDS = [
621
621
  },
622
622
  {
623
623
  name: 'todo',
624
+ // Same verb parse as `run` below, so the echo is suppressed exactly when a card can appear (I-8).
625
+ rendersInvocationCard: (args) => args.trim().toLowerCase().split(/\s+/)[0] === 'continue',
624
626
  description: 'Todo planning: status | on | off | panel on|off | follow <mode> | ' +
625
627
  'acceptance [on|off|reset] | clear | continue',
626
628
  /**
@@ -782,6 +784,16 @@ const COMMANDS = [
782
784
  // the more verbose of the two forms (AC-17).
783
785
  ctx.submit(buildContinuationMessage(snapshot), {
784
786
  userInitiated: false, todoPolicy: 'continue',
787
+ // The transcript shows a card instead of the canned paragraph; the payload above
788
+ // is unchanged (tui-invocation-cards I-1).
789
+ ...(ctx.invocationCards === false ? {} : {
790
+ invocation: {
791
+ variant: 'todo',
792
+ name: 'continue',
793
+ commandLine: '/todo continue',
794
+ facts: [`${Math.max(0, snapshot.total - snapshot.doneCount)} of ${snapshot.total} steps left`],
795
+ },
796
+ }),
785
797
  });
786
798
  return;
787
799
  }
@@ -198,7 +198,17 @@ async function resume(ctx) {
198
198
  return;
199
199
  // `userInitiated: false` keeps the canned turn out of the prompt history, exactly like
200
200
  // `/todo continue` (todo-plan-followthrough C-12).
201
- ctx.submit(resumeTurnText(runId), { userInitiated: false });
201
+ ctx.submit(resumeTurnText(runId), {
202
+ userInitiated: false,
203
+ ...(ctx.invocationCards === false ? {} : {
204
+ invocation: {
205
+ variant: 'govern',
206
+ name: 'resume',
207
+ commandLine: requested === '' ? '/gov resume' : `/gov resume ${requested}`,
208
+ facts: [`run ${runId.slice(0, 8)}`],
209
+ },
210
+ }),
211
+ });
202
212
  }
203
213
  function abort(ctx) {
204
214
  if (!ctx.controller.isGovernanceStageRunning()) {
@@ -286,6 +296,8 @@ export async function runGovernanceCommand(ctx) {
286
296
  export function registerGovernanceCommand(registry) {
287
297
  registry.register({
288
298
  name: 'gov',
299
+ // Same verb parse as `runGovernanceCommand`, so the echo is suppressed exactly when a card can appear (I-8).
300
+ rendersInvocationCard: (args) => (args.trim().split(/\s+/)[0] ?? '').toLowerCase() === 'resume',
289
301
  description: GOV_COMMAND_SUMMARY,
290
302
  run: (ctx) => runGovernanceCommand(ctx),
291
303
  });
@@ -26,6 +26,14 @@ export declare function hasProjectIndex(cwd: string): boolean;
26
26
  * second time (the skill's own edge-case list says exactly that).
27
27
  */
28
28
  export declare function indexBuildInvocationArgs(hasExistingIndex: boolean): string;
29
+ /**
30
+ * True when `args` is one of the two machine-written sentences above. The invocation
31
+ * card uses this to NOT present them as something the user said (tui-invocation-cards
32
+ * RV-3): it shows a `ctrl+i` fact instead. Same file as the generator so the two cannot
33
+ * drift - a reworded sentence that stops matching would silently put machine words
34
+ * back on the card.
35
+ */
36
+ export declare function isIndexBuildArgs(args: string): boolean;
29
37
  export interface IndexConfirmInput {
30
38
  cwd: string;
31
39
  /** True when `.claude-index/index.md` already exists (rebuild, not first build). */
@@ -40,6 +40,17 @@ export function indexBuildInvocationArgs(hasExistingIndex) {
40
40
  ? 'The user pressed Ctrl+I and confirmed. Rebuild the project index for this workspace now, reusing the existing .claude-index/config.md preferences.'
41
41
  : 'The user pressed Ctrl+I and confirmed. Build the project index for this workspace now, asking the configuration questions first.';
42
42
  }
43
+ /**
44
+ * True when `args` is one of the two machine-written sentences above. The invocation
45
+ * card uses this to NOT present them as something the user said (tui-invocation-cards
46
+ * RV-3): it shows a `ctrl+i` fact instead. Same file as the generator so the two cannot
47
+ * drift - a reworded sentence that stops matching would silently put machine words
48
+ * back on the card.
49
+ */
50
+ export function isIndexBuildArgs(args) {
51
+ const text = args.trim();
52
+ return text === indexBuildInvocationArgs(true) || text === indexBuildInvocationArgs(false);
53
+ }
43
54
  /**
44
55
  * The Yes/No dialog body. It names WHAT WILL BE WRITTEN - an index run is a
45
56
  * batch of file writes the user never typed, and the one question a confirm
@@ -201,6 +201,13 @@ export interface CommandContext {
201
201
  * TYPE-ONLY import, for the reason the `branch` note above records.
202
202
  */
203
203
  taskSummary?: TaskSummaryCommandPort;
204
+ /**
205
+ * Whether transcript invocation cards are on (tui-invocation-cards section 3.6).
206
+ * `false` only under `ARAGON_NO_INVOCATION_CARDS=1`; commands then fall back to the
207
+ * pre-card notices and submit without `invocation`. Absent (every fixture built by
208
+ * literal) means ON.
209
+ */
210
+ invocationCards?: boolean;
204
211
  }
205
212
  /**
206
213
  * What `/keys` and `/task` may do.
@@ -240,6 +247,12 @@ export interface SlashCommand {
240
247
  aliases?: string[];
241
248
  description: string;
242
249
  run: (ctx: CommandContext) => void | Promise<void>;
250
+ /**
251
+ * True when running with these args will submit a message that renders as an invocation
252
+ * card, so the `Command:` echo would only repeat it. MUST be the same predicate the
253
+ * command itself branches on (I-8); absent means false.
254
+ */
255
+ rendersInvocationCard?: (args: string) => boolean;
243
256
  }
244
257
  export declare class CommandRegistry {
245
258
  private readonly byName;
@@ -247,6 +260,16 @@ export declare class CommandRegistry {
247
260
  get(name: string): SlashCommand | undefined;
248
261
  all(): SlashCommand[];
249
262
  }
263
+ /**
264
+ * Split raw composer input into a command word and its trimmed args; `null` when the
265
+ * input is not a slash command (no leading `/`, or the `//` literal-slash escape).
266
+ * `runSlashInput` and the App's echo suppression both go through here so they cannot
267
+ * disagree about what counts as a command.
268
+ */
269
+ export declare function parseSlashInput(input: string): {
270
+ name: string;
271
+ args: string;
272
+ } | null;
250
273
  /**
251
274
  * Try to run a slash command from raw input. Returns `true` if the input was a
252
275
  * (known or unknown) slash command and was consumed; `false` if it is a normal
@@ -19,21 +19,34 @@ export class CommandRegistry {
19
19
  }
20
20
  }
21
21
  /**
22
- * Try to run a slash command from raw input. Returns `true` if the input was a
23
- * (known or unknown) slash command and was consumed; `false` if it is a normal
24
- * message that should go to the agent.
22
+ * Split raw composer input into a command word and its trimmed args; `null` when the
23
+ * input is not a slash command (no leading `/`, or the `//` literal-slash escape).
24
+ * `runSlashInput` and the App's echo suppression both go through here so they cannot
25
+ * disagree about what counts as a command.
25
26
  */
26
- export async function runSlashInput(registry, input, makeCtx) {
27
+ export function parseSlashInput(input) {
27
28
  const trimmed = input.trimStart();
28
29
  if (!trimmed.startsWith('/'))
29
- return false;
30
- // `//...` escapes a literal leading slash — not a command.
30
+ return null;
31
+ // `//...` escapes a literal leading slash - not a command.
31
32
  if (trimmed.startsWith('//'))
32
- return false;
33
+ return null;
33
34
  const withoutSlash = trimmed.slice(1);
34
35
  const spaceIdx = withoutSlash.search(/\s/);
35
36
  const name = spaceIdx === -1 ? withoutSlash : withoutSlash.slice(0, spaceIdx);
36
37
  const args = spaceIdx === -1 ? '' : withoutSlash.slice(spaceIdx + 1).trim();
38
+ return { name, args };
39
+ }
40
+ /**
41
+ * Try to run a slash command from raw input. Returns `true` if the input was a
42
+ * (known or unknown) slash command and was consumed; `false` if it is a normal
43
+ * message that should go to the agent.
44
+ */
45
+ export async function runSlashInput(registry, input, makeCtx) {
46
+ const parsed = parseSlashInput(input);
47
+ if (parsed === null)
48
+ return false;
49
+ const { name, args } = parsed;
37
50
  const command = registry.get(name);
38
51
  const ctx = makeCtx(args);
39
52
  if (!command) {
@@ -16,6 +16,7 @@ import { SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
16
16
  import { HOST_TOOL_NAMES, SKILL_TOOL_FLOOR } from '../tools/index.js';
17
17
  import { SKILLS_TOOL_POLICY_MODES, isSkillsToolPolicyMode } from '../config/schema.js';
18
18
  import { pickGlyphs } from '../ui/glyphs.js';
19
+ import { INDEX_SKILL_NAME, isIndexBuildArgs } from './index-build.js';
19
20
  function marksFor(ctx) {
20
21
  const cfg = ctx.controller.getConfig();
21
22
  const glyphs = pickGlyphs({ colorLevel: cfg.colorLevel ?? 0, unicode: cfg.unicode ?? false });
@@ -673,10 +674,37 @@ export function registerSkillCommands(registry, service) {
673
674
  }
674
675
  }
675
676
  }
677
+ /**
678
+ * The card's raw material for one skill run (tui-invocation-cards section 3.3). The
679
+ * command line always uses the namespaced `/skill:<name>` form - the stable handle -
680
+ * even when the user typed the bare name. Ctrl+I's machine-written sentence is NOT the
681
+ * user's input (RV-3): the card says `ctrl+i` and shows no input row.
682
+ */
683
+ function skillCardInput(record, args, sent) {
684
+ const machine = record.name === INDEX_SKILL_NAME && isIndexBuildArgs(args);
685
+ const version = record.frontmatter.version;
686
+ const facts = [
687
+ ...(machine ? ['ctrl+i'] : []),
688
+ ...(version ? [`v${version}`] : []),
689
+ record.scope,
690
+ `${sent.kb} KB`,
691
+ ...(sent.files > 0 ? [`${sent.files} files`] : []),
692
+ ...(sent.policy ? ['tool ceiling'] : []),
693
+ ];
694
+ const typed = machine ? '' : args;
695
+ return {
696
+ variant: 'skill',
697
+ name: record.name,
698
+ commandLine: typed === '' ? `/skill:${record.name}` : `/skill:${record.name} ${typed}`,
699
+ input: typed,
700
+ facts,
701
+ };
702
+ }
676
703
  function makeSkillCommand(record, service) {
677
704
  return {
678
705
  name: record.name,
679
706
  description: shortDescription(record),
707
+ rendersInvocationCard: () => true,
680
708
  run: (ctx) => {
681
709
  let loaded;
682
710
  try {
@@ -703,8 +731,14 @@ function makeSkillCommand(record, service) {
703
731
  // The SUBMITTED size, not the file's. Reporting 480 KB while sending 30 KB
704
732
  // is how a user concludes the truncation marker is a rendering glitch.
705
733
  const kb = Math.max(1, Math.round(Buffer.byteLength(message, 'utf-8') / 1024));
706
- ctx.notify('info', `Skill "${record.name}" loaded (${kb} KB submitted, ${loaded.files.length} bundled files).`);
707
- ctx.submit(message);
734
+ if (ctx.invocationCards === false) {
735
+ ctx.notify('info', `Skill "${record.name}" loaded (${kb} KB submitted, ${loaded.files.length} bundled files).`);
736
+ ctx.submit(message);
737
+ return;
738
+ }
739
+ ctx.submit(message, {
740
+ invocation: skillCardInput(record, ctx.args, { kb, files: loaded.files.length, policy: !!policy }),
741
+ });
708
742
  },
709
743
  };
710
744
  }