opencode-discipline 0.1.1 → 0.1.3

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.
@@ -0,0 +1,43 @@
1
+ ---
2
+ description: Fast codebase search. Finds files, patterns, and structure quickly. Cheap and parallel-friendly.
3
+ model: openai/gpt-4.1-mini
4
+ temperature: 0
5
+ mode: subagent
6
+ color: "#78909C"
7
+ tools:
8
+ read: true
9
+ write: false
10
+ edit: false
11
+ bash: true
12
+ glob: true
13
+ grep: true
14
+ task: false
15
+ permission:
16
+ bash:
17
+ "*": deny
18
+ "rtk *": allow
19
+ "find *": allow
20
+ "ls *": allow
21
+ "cat *": allow
22
+ "head *": allow
23
+ "tail *": allow
24
+ "wc *": allow
25
+ "git log *": allow
26
+ "git show *": allow
27
+ ---
28
+
29
+ You are a fast codebase explorer. Your job is to find files, patterns, and structure as quickly as possible. You never implement or modify anything.
30
+
31
+ ## How you work
32
+
33
+ 1. Start with project structure — `ls`, file tree, config files
34
+ 2. Use Glob for file patterns, Grep for content search
35
+ 3. Read only what's relevant — don't dump entire files
36
+ 4. Return concise findings with exact file paths and line numbers
37
+
38
+ ## Rules
39
+
40
+ - NEVER write or edit files
41
+ - Be fast — breadth first, then drill into relevant areas
42
+ - Return structured results the caller can act on immediately
43
+ - If you can't find what's needed, say so
package/agents/plan.md CHANGED
@@ -27,6 +27,12 @@ permission:
27
27
  "cargo check *": allow
28
28
  "cargo test --no-run *": allow
29
29
  "npm run type-check *": allow
30
+ "bun run type-check *": allow
31
+ "bun run type-check": allow
32
+ "bun test *": allow
33
+ "bun test": allow
34
+ "bun run build *": allow
35
+ "bun run build": allow
30
36
  "find *": allow
31
37
  "wc *": allow
32
38
  "mkdir *": allow
@@ -148,10 +154,13 @@ For high-stakes plans (production changes, data migrations, auth refactors):
148
154
 
149
155
  ### Presenting the plan
150
156
 
151
- After writing:
157
+ After writing and completing Wave 4 review:
152
158
  1. Tell the user: "Plan written to `tasks/plans/{filename}.md`"
153
159
  2. Summarize in 3-5 sentences
154
- 3. Say: "Review and edit the plan, then switch to Build to execute. Or ask me to revise."
160
+ 3. Present exactly three choices and ask the user to pick one:
161
+ - **Accept plan & start work** — Call `accept_plan(action="accept")` to create a new Build session with the plan registered. The Build agent will start implementing immediately.
162
+ - **Start work later** — Do nothing. The plan is saved and the user can start a Build session manually whenever they're ready.
163
+ - **I have modifications to make** — Call `accept_plan(action="revise")` and stay in Wave 4 to incorporate the user's feedback. After revisions, present these three choices again.
155
164
 
156
165
  ## Rules
157
166
 
@@ -162,3 +171,4 @@ After writing:
162
171
  - Prefer small phases (2-4 items). If a phase has 5+ items, split it.
163
172
  - Interleave tests with implementation — never "Phase N: write all tests."
164
173
  - A good plan lets the Build agent one-shot the implementation. That's the bar.
174
+ - `advance_wave` and `accept_plan` are plugin tools you MUST call — they are not implementation actions. Calling them is part of your planning workflow, not a violation of read-only mode.
package/dist/index.js CHANGED
@@ -12619,20 +12619,18 @@ var WAVE_NEXT_STEPS = {
12619
12619
  3: "writing the plan file",
12620
12620
  4: "plan review and refinement"
12621
12621
  };
12622
+ function hasStringProp(obj, key) {
12623
+ return key in obj && typeof obj[key] === "string";
12624
+ }
12622
12625
  function extractSessionID(result) {
12623
12626
  if (!result || typeof result !== "object") {
12624
12627
  return;
12625
12628
  }
12626
- const value = result;
12627
- if (typeof value.id === "string") {
12628
- return value.id;
12629
+ if (hasStringProp(result, "id")) {
12630
+ return result.id;
12629
12631
  }
12630
- const data = value.data;
12631
- if (data && typeof data === "object") {
12632
- const dataRecord = data;
12633
- if (typeof dataRecord.id === "string") {
12634
- return dataRecord.id;
12635
- }
12632
+ if ("data" in result && result.data && typeof result.data === "object" && hasStringProp(result.data, "id")) {
12633
+ return result.data.id;
12636
12634
  }
12637
12635
  return;
12638
12636
  }
@@ -12735,11 +12733,8 @@ function extractTodos(response) {
12735
12733
  return [];
12736
12734
  }
12737
12735
  function hasPlanKickoffTodo(todos, planName) {
12738
- const planNameLower = planName.toLowerCase();
12739
- return todos.some((todo) => {
12740
- const content = todo.content.toLowerCase();
12741
- return content.includes(planNameLower) && content.includes("plan");
12742
- });
12736
+ const expected = buildTodoSeedContent(planName).toLowerCase();
12737
+ return todos.some((todo) => todo.content.toLowerCase().includes(expected));
12743
12738
  }
12744
12739
  async function readSessionTodos(client, directory, sessionID) {
12745
12740
  const sessionApi = client.session;
@@ -12752,197 +12747,212 @@ async function readSessionTodos(client, directory, sessionID) {
12752
12747
  path: { id: sessionID }
12753
12748
  });
12754
12749
  return extractTodos(response);
12755
- } catch {
12750
+ } catch (error45) {
12751
+ console.warn("[discipline] readSessionTodos failed:", error45);
12756
12752
  return;
12757
12753
  }
12758
12754
  }
12759
- var DisciplinePlugin = async ({ worktree, directory, client }) => {
12760
- const manager = new WaveStateManager(worktree);
12761
- const todoNudges = new Map;
12762
- return {
12763
- "experimental.session.compacting": async (input, output) => {
12764
- const state = manager.getState(input.sessionID);
12765
- if (!state) {
12766
- return;
12767
- }
12768
- output.context.push(buildCompactionContext(state));
12769
- },
12770
- "experimental.chat.system.transform": async (input, output) => {
12771
- const sessionID = input.sessionID;
12772
- const state = sessionID ? manager.getState(sessionID) : undefined;
12773
- if (!state) {
12774
- output.system.push("## Discipline Plugin\nYou are operating under the opencode-discipline plugin. To start a structured plan, call `advance_wave` to begin Wave 1 (Interview).");
12775
- return;
12755
+ async function handleCompacting(ctx, input, output) {
12756
+ const state = ctx.manager.getState(input.sessionID);
12757
+ if (!state) {
12758
+ return;
12759
+ }
12760
+ output.context.push(buildCompactionContext(state));
12761
+ }
12762
+ async function handleSystemTransform(ctx, input, output) {
12763
+ const sessionID = input.sessionID;
12764
+ const state = sessionID ? ctx.manager.getState(sessionID) : undefined;
12765
+ if (!state) {
12766
+ output.system.push("## Discipline Plugin\nYou are operating under the opencode-discipline plugin. To start a structured plan, call `advance_wave` to begin Wave 1 (Interview).");
12767
+ return;
12768
+ }
12769
+ output.system.push(buildWaveStateSystemBlock(state.wave, state.planName));
12770
+ const currentAgent = input.agent;
12771
+ if (currentAgent === "plan" && state.wave < 3) {
12772
+ output.system.push(buildPlanReadOnlyReminder());
12773
+ }
12774
+ if (sessionID && currentAgent === "plan" && !state.accepted) {
12775
+ const nudgeState = ctx.todoNudges.get(sessionID) ?? {
12776
+ prompted: false,
12777
+ retried: false,
12778
+ seeded: false
12779
+ };
12780
+ const todos = await readSessionTodos(ctx.client, ctx.directory, sessionID);
12781
+ const hasSeededTodo = todos !== undefined && hasPlanKickoffTodo(todos, state.planName);
12782
+ if (hasSeededTodo) {
12783
+ nudgeState.seeded = true;
12784
+ }
12785
+ if (!nudgeState.seeded) {
12786
+ if (!nudgeState.prompted) {
12787
+ output.system.push(buildTodoSeedInstruction(state.planName, false));
12788
+ nudgeState.prompted = true;
12789
+ } else if (todos !== undefined && !hasSeededTodo && !nudgeState.retried) {
12790
+ output.system.push(buildTodoSeedInstruction(state.planName, true));
12791
+ nudgeState.retried = true;
12776
12792
  }
12777
- output.system.push(buildWaveStateSystemBlock(state.wave, state.planName));
12778
- const currentAgent = input.agent;
12779
- if (currentAgent === "plan" && state.wave < 3) {
12780
- output.system.push(buildPlanReadOnlyReminder());
12793
+ }
12794
+ ctx.todoNudges.set(sessionID, nudgeState);
12795
+ }
12796
+ }
12797
+ async function handleToolExecuteBefore(ctx, input, output) {
12798
+ const filePath = output.args?.filePath;
12799
+ if (typeof filePath === "string") {
12800
+ if (input.tool === "read" && isBlockedEnvRead(filePath)) {
12801
+ throw new Error("Reading .env files is blocked by opencode-discipline.");
12802
+ }
12803
+ if (input.tool === "edit" || input.tool === "write") {
12804
+ const state = ctx.manager.getState(input.sessionID);
12805
+ if (state?.accepted) {
12806
+ throw new Error('Plan session is read-only after accept_plan(action="accept"). Switch to Build for implementation.');
12781
12807
  }
12782
- if (sessionID && currentAgent === "plan" && !state.accepted) {
12783
- const nudgeState = todoNudges.get(sessionID) ?? {
12784
- prompted: false,
12785
- retried: false,
12786
- seeded: false
12787
- };
12788
- const todos = await readSessionTodos(client, directory, sessionID);
12789
- const hasSeededTodo = todos !== undefined && hasPlanKickoffTodo(todos, state.planName);
12790
- if (hasSeededTodo) {
12791
- nudgeState.seeded = true;
12792
- }
12793
- if (!nudgeState.seeded) {
12794
- if (!nudgeState.prompted) {
12795
- output.system.push(buildTodoSeedInstruction(state.planName, false));
12796
- nudgeState.prompted = true;
12797
- } else if (todos !== undefined && !hasSeededTodo && !nudgeState.retried) {
12798
- output.system.push(buildTodoSeedInstruction(state.planName, true));
12799
- nudgeState.retried = true;
12800
- }
12801
- }
12802
- todoNudges.set(sessionID, nudgeState);
12808
+ if (state && state.wave < 3 && isPlanMarkdownFile(ctx.worktree, filePath)) {
12809
+ throw new Error(`Cannot write plan files until Wave 3 (Plan Generation). Current wave: ${state.wave}. Call advance_wave to progress.`);
12803
12810
  }
12811
+ }
12812
+ }
12813
+ }
12814
+ function createAdvanceWaveTool(ctx) {
12815
+ return tool({
12816
+ description: "Advance the planning wave. Call this to move from one wave to the next. Wave 1: Interview, Wave 2: Gap Analysis, Wave 3: Plan Generation, Wave 4: Review. You must call this before starting each wave. The first call starts a new plan and generates the plan filename.",
12817
+ args: {
12818
+ sessionID: tool.schema.string().describe("The current session ID")
12804
12819
  },
12805
- "tool.execute.before": async (input, output) => {
12806
- const filePath = output.args?.filePath;
12807
- if (typeof filePath === "string") {
12808
- if (input.tool === "read" && isBlockedEnvRead(filePath)) {
12809
- throw new Error("Reading .env files is blocked by opencode-discipline.");
12810
- }
12811
- if (input.tool === "edit" || input.tool === "write") {
12812
- const state = manager.getState(input.sessionID);
12813
- if (state?.accepted) {
12814
- throw new Error('Plan session is read-only after accept_plan(action="accept"). Switch to Build for implementation.');
12815
- }
12816
- if (state && state.wave < 3 && isPlanMarkdownFile(worktree, filePath)) {
12817
- throw new Error(`Cannot write plan files until Wave 3 (Plan Generation). Current wave: ${state.wave}. Call advance_wave to progress.`);
12818
- }
12820
+ async execute(args, context) {
12821
+ if (context.agent !== "plan") {
12822
+ return "Error: advance_wave is only available to the Plan agent.";
12823
+ }
12824
+ try {
12825
+ const current = ctx.manager.getState(args.sessionID);
12826
+ const state = current ? ctx.manager.advanceWave(args.sessionID) : ctx.manager.startPlan(args.sessionID);
12827
+ if (!current) {
12828
+ ctx.todoNudges.set(args.sessionID, {
12829
+ prompted: false,
12830
+ retried: false,
12831
+ seeded: false
12832
+ });
12819
12833
  }
12834
+ const waveName = WAVE_NAMES[state.wave];
12835
+ const nextStep = WAVE_NEXT_STEPS[state.wave];
12836
+ return `Wave ${state.wave} (${waveName}) started for plan '${state.planName}'. You may now proceed with ${nextStep}.`;
12837
+ } catch (error45) {
12838
+ const message = error45 instanceof Error ? error45.message : "Unknown error";
12839
+ return `Error: ${message}`;
12820
12840
  }
12841
+ }
12842
+ });
12843
+ }
12844
+ function createAcceptPlanTool(ctx) {
12845
+ return tool({
12846
+ description: "Accept or revise the generated plan. Use action='revise' to keep planning in Wave 4, or action='accept' to hand off to Build with only the plan path.",
12847
+ args: {
12848
+ sessionID: tool.schema.string().describe("The current plan session ID"),
12849
+ action: tool.schema.enum(["accept", "revise"]),
12850
+ planPath: tool.schema.string().optional().describe("Optional relative or absolute path to the plan file")
12821
12851
  },
12822
- tool: {
12823
- advance_wave: tool({
12824
- description: "Advance the planning wave. Call this to move from one wave to the next. Wave 1: Interview, Wave 2: Gap Analysis, Wave 3: Plan Generation, Wave 4: Review. You must call this before starting each wave. The first call starts a new plan and generates the plan filename.",
12825
- args: {
12826
- sessionID: tool.schema.string().describe("The current session ID")
12827
- },
12828
- async execute(args, context) {
12829
- if (context.agent !== "plan") {
12830
- return "Error: advance_wave is only available to the Plan agent.";
12831
- }
12832
- try {
12833
- const current = manager.getState(args.sessionID);
12834
- const state = current ? manager.advanceWave(args.sessionID) : manager.startPlan(args.sessionID);
12835
- if (!current) {
12836
- todoNudges.set(args.sessionID, {
12837
- prompted: false,
12838
- retried: false,
12839
- seeded: false
12840
- });
12841
- }
12842
- const waveName = WAVE_NAMES[state.wave];
12843
- const nextStep = WAVE_NEXT_STEPS[state.wave];
12844
- return `Wave ${state.wave} (${waveName}) started for plan '${state.planName}'. You may now proceed with ${nextStep}.`;
12845
- } catch (error45) {
12846
- const message = error45 instanceof Error ? error45.message : "Unknown error";
12847
- return `Error: ${message}`;
12848
- }
12849
- }
12850
- }),
12851
- accept_plan: tool({
12852
- description: "Accept or revise the generated plan. Use action='revise' to keep planning in Wave 4, or action='accept' to hand off to Build with only the plan path.",
12853
- args: {
12854
- sessionID: tool.schema.string().describe("The current plan session ID"),
12855
- action: tool.schema.enum(["accept", "revise"]),
12856
- planPath: tool.schema.string().optional().describe("Optional relative or absolute path to the plan file")
12857
- },
12858
- async execute(args, context) {
12859
- if (context.agent !== "plan") {
12860
- return "Error: accept_plan is only available to the Plan agent.";
12861
- }
12862
- const state = manager.getState(args.sessionID);
12863
- if (!state) {
12864
- return `Error: No active plan found for session '${args.sessionID}'.`;
12865
- }
12866
- const defaultPlanPath = `tasks/plans/${state.planName}.md`;
12867
- const resolvedPlanPath = args.planPath ?? defaultPlanPath;
12868
- const absolutePlanPath = resolve(worktree, resolvedPlanPath);
12869
- const relativePlanPath = toWorktreeRelativePath(worktree, absolutePlanPath);
12870
- if (!isPlanMarkdownFile(worktree, absolutePlanPath)) {
12871
- return "Error: planPath must point to tasks/plans/{planName}.md within the current worktree.";
12872
- }
12873
- if (args.action === "revise") {
12874
- const nextStep = state.wave < 4 ? "Next step: call advance_wave until Wave 4, then continue review/revision." : "Next step: continue in Wave 4 review/revision and update the plan as needed.";
12875
- return [
12876
- "Plan revision requested.",
12877
- `Current wave: ${state.wave} (${getWaveLabel(state.wave)}).`,
12878
- `Plan file: ${relativePlanPath}`,
12879
- nextStep
12880
- ].join(" ");
12881
- }
12882
- if (state.wave !== 4) {
12883
- return `Error: accept_plan(action="accept") is only available in Wave 4 (Review). Current wave: ${state.wave}.`;
12884
- }
12885
- try {
12886
- accessSync(absolutePlanPath, constants.R_OK);
12887
- } catch {
12888
- return `Error: Plan file '${relativePlanPath}' is missing or unreadable.`;
12889
- }
12890
- let buildSessionID = "manual-build-session";
12891
- let fallback = false;
12892
- const sessionApi = client.session;
12893
- const canCreate = sessionApi && typeof sessionApi.create === "function";
12894
- const canPrompt = sessionApi && typeof sessionApi.prompt === "function";
12895
- if (canCreate && canPrompt) {
12896
- try {
12897
- const createResult = await sessionApi.create({
12898
- query: { directory },
12899
- body: { title: `Build handoff: ${state.planName}` }
12900
- });
12901
- const createdSessionID = extractSessionID(createResult);
12902
- if (!createdSessionID) {
12903
- fallback = true;
12904
- } else {
12905
- buildSessionID = createdSessionID;
12906
- await sessionApi.prompt({
12907
- query: { directory },
12908
- path: { id: createdSessionID },
12909
- body: {
12910
- agent: "build",
12911
- system: "Use only this file as source of truth; do not rely on Plan context.",
12912
- parts: [
12913
- {
12914
- type: "text",
12915
- text: `Read ${relativePlanPath} and implement it phase-by-phase. Use only this plan file as handoff context.`
12916
- }
12917
- ]
12852
+ async execute(args, context) {
12853
+ if (context.agent !== "plan") {
12854
+ return "Error: accept_plan is only available to the Plan agent.";
12855
+ }
12856
+ const state = ctx.manager.getState(args.sessionID);
12857
+ if (!state) {
12858
+ return `Error: No active plan found for session '${args.sessionID}'.`;
12859
+ }
12860
+ const defaultPlanPath = `tasks/plans/${state.planName}.md`;
12861
+ const resolvedPlanPath = args.planPath ?? defaultPlanPath;
12862
+ const absolutePlanPath = resolve(ctx.worktree, resolvedPlanPath);
12863
+ const relativePlanPath = toWorktreeRelativePath(ctx.worktree, absolutePlanPath);
12864
+ if (!isPlanMarkdownFile(ctx.worktree, absolutePlanPath)) {
12865
+ return "Error: planPath must point to tasks/plans/{planName}.md within the current worktree.";
12866
+ }
12867
+ if (args.action === "revise") {
12868
+ const nextStep = state.wave < 4 ? "Next step: call advance_wave until Wave 4, then continue review/revision." : "Next step: continue in Wave 4 review/revision and update the plan as needed.";
12869
+ return [
12870
+ "Plan revision requested.",
12871
+ `Current wave: ${state.wave} (${getWaveLabel(state.wave)}).`,
12872
+ `Plan file: ${relativePlanPath}`,
12873
+ nextStep
12874
+ ].join(" ");
12875
+ }
12876
+ if (state.wave !== 4) {
12877
+ return `Error: accept_plan(action="accept") is only available in Wave 4 (Review). Current wave: ${state.wave}.`;
12878
+ }
12879
+ try {
12880
+ accessSync(absolutePlanPath, constants.R_OK);
12881
+ } catch {
12882
+ return `Error: Plan file '${relativePlanPath}' is missing or unreadable.`;
12883
+ }
12884
+ let buildSessionID = "manual-build-session";
12885
+ let fallback = false;
12886
+ const sessionApi = ctx.client.session;
12887
+ const canCreate = sessionApi && typeof sessionApi.create === "function";
12888
+ const canPrompt = sessionApi && typeof sessionApi.prompt === "function";
12889
+ if (canCreate && canPrompt) {
12890
+ try {
12891
+ const createResult = await sessionApi.create({
12892
+ query: { directory: ctx.directory },
12893
+ body: { title: `Build handoff: ${state.planName}` }
12894
+ });
12895
+ const createdSessionID = extractSessionID(createResult);
12896
+ if (!createdSessionID) {
12897
+ fallback = true;
12898
+ } else {
12899
+ buildSessionID = createdSessionID;
12900
+ await sessionApi.prompt({
12901
+ query: { directory: ctx.directory },
12902
+ path: { id: createdSessionID },
12903
+ body: {
12904
+ agent: "build",
12905
+ system: "Use only this file as source of truth; do not rely on Plan context.",
12906
+ parts: [
12907
+ {
12908
+ type: "text",
12909
+ text: `Read ${relativePlanPath} and implement it phase-by-phase. Use only this plan file as handoff context.`
12918
12910
  }
12919
- });
12911
+ ]
12920
12912
  }
12921
- } catch {
12922
- fallback = true;
12923
- }
12924
- } else {
12925
- fallback = true;
12926
- }
12927
- const acceptedState = manager.markAccepted(args.sessionID, buildSessionID);
12928
- if (fallback) {
12929
- return [
12930
- "Plan accepted.",
12931
- `Plan file: ${relativePlanPath}`,
12932
- "Direct session handoff is unavailable in this environment.",
12933
- "Fallback: switch to Build agent and read the plan file first.",
12934
- `State saved with accepted timestamp ${acceptedState.acceptedAt}.`
12935
- ].join(" ");
12913
+ });
12936
12914
  }
12937
- return [
12938
- "Plan accepted and handed off to Build.",
12939
- `Plan file: ${relativePlanPath}`,
12940
- `Build session: ${buildSessionID}`,
12941
- "First build action seeded: read the plan file with clean handoff context.",
12942
- `State saved with accepted timestamp ${acceptedState.acceptedAt}.`
12943
- ].join(" ");
12944
- }
12945
- })
12915
+ } catch {
12916
+ fallback = true;
12917
+ }
12918
+ } else {
12919
+ fallback = true;
12920
+ }
12921
+ const acceptedState = ctx.manager.markAccepted(args.sessionID, buildSessionID);
12922
+ if (fallback) {
12923
+ return [
12924
+ "Plan accepted.",
12925
+ `Plan file: ${relativePlanPath}`,
12926
+ "Direct session handoff is unavailable in this environment.",
12927
+ "Fallback: switch to Build agent and read the plan file first.",
12928
+ `State saved with accepted timestamp ${acceptedState.acceptedAt}.`
12929
+ ].join(" ");
12930
+ }
12931
+ return [
12932
+ "Plan accepted and handed off to Build.",
12933
+ `Plan file: ${relativePlanPath}`,
12934
+ `Build session: ${buildSessionID}`,
12935
+ "First build action seeded: read the plan file with clean handoff context.",
12936
+ `State saved with accepted timestamp ${acceptedState.acceptedAt}.`
12937
+ ].join(" ");
12938
+ }
12939
+ });
12940
+ }
12941
+ var DisciplinePlugin = async ({ worktree, directory, client }) => {
12942
+ const ctx = {
12943
+ manager: new WaveStateManager(worktree),
12944
+ todoNudges: new Map,
12945
+ worktree,
12946
+ directory,
12947
+ client
12948
+ };
12949
+ return {
12950
+ "experimental.session.compacting": (input, output) => handleCompacting(ctx, input, output),
12951
+ "experimental.chat.system.transform": (input, output) => handleSystemTransform(ctx, input, output),
12952
+ "tool.execute.before": (input, output) => handleToolExecuteBefore(ctx, input, output),
12953
+ tool: {
12954
+ advance_wave: createAdvanceWaveTool(ctx),
12955
+ accept_plan: createAcceptPlanTool(ctx)
12946
12956
  }
12947
12957
  };
12948
12958
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discipline",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",