throughline 0.13.0 → 0.14.0
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/CHANGELOG.md +25 -1
- package/README.ja.md +14 -7
- package/README.md +14 -7
- package/bin/throughline.mjs +10 -4
- package/docs/02_clear_auto_handoff_plan.md +2 -2
- package/docs/05_codex_first_roadmap.md +3 -2
- package/docs/adr/0032-claude-compact-continuation.md +2 -0
- package/docs/adr/0033-claude-auto-handoff-new-session.md +86 -0
- package/package.json +2 -2
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +28 -3
- package/rag/INDEX.md +1 -1
- package/src/claude-auto-handoff.mjs +419 -137
- package/src/claude-auto-handoff.test.mjs +489 -193
- package/src/claude-pre-compact.mjs +21 -7
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +24 -18
- package/src/cli/install.mjs +67 -18
- package/src/cli/install.test.mjs +22 -4
- package/src/handoff-executor.mjs +24 -12
- package/src/hosts/claude.mjs +10 -0
- package/src/prompt-submit.mjs +1 -0
- package/src/resume-context.mjs +26 -34
- package/src/resume-context.test.mjs +26 -37
- package/src/runtime-error-store.mjs +6 -1
- package/src/session-start.mjs +8 -28
- package/src/transcript-reader.mjs +4 -4
- package/src/transcript-reader.test.mjs +19 -0
- package/src/turn-backfill.mjs +2 -7
- package/src/turn-backfill.test.mjs +0 -22
- package/src/turn-processor.mjs +4 -0
|
@@ -4,7 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
4
4
|
import {
|
|
5
5
|
buildResumeContext,
|
|
6
6
|
buildBudgetedResumeContext,
|
|
7
|
-
|
|
7
|
+
buildAutoContinuationContext,
|
|
8
8
|
INJECTION_BUDGET_CHARS,
|
|
9
9
|
} from './resume-context.mjs';
|
|
10
10
|
|
|
@@ -907,73 +907,62 @@ test('budgeted: header and anchor always survive even under pressure', () => {
|
|
|
907
907
|
assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
|
|
908
908
|
});
|
|
909
909
|
|
|
910
|
-
// ---
|
|
910
|
+
// --- 自動継続で立てた新しい会話への注入 (ADR 0033) ---
|
|
911
911
|
|
|
912
|
-
test('
|
|
912
|
+
test('buildAutoContinuationContext: 保存済みのターンが無くても、止めた時点の依頼だけで組み立てる', () => {
|
|
913
913
|
const db = makeDb();
|
|
914
|
-
const result =
|
|
914
|
+
const result = buildAutoContinuationContext(db, {
|
|
915
915
|
sessionId: 'S',
|
|
916
|
-
inFlight: { user: { content: '最初の依頼', timestamp: null },
|
|
916
|
+
inFlight: { user: { content: '最初の依頼', timestamp: null }, last_fragment: null },
|
|
917
917
|
});
|
|
918
|
-
assert.match(result.text, /^## Throughline:
|
|
919
|
-
assert.match(result.text, /### 現在地 \(
|
|
920
|
-
assert.doesNotMatch(result.text,
|
|
918
|
+
assert.match(result.text, /^## Throughline: 自動継続の文脈\n/);
|
|
919
|
+
assert.match(result.text, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\*: 最初の依頼\n/);
|
|
920
|
+
assert.doesNotMatch(result.text, /止める直前のあなたの発言/);
|
|
921
921
|
assert.doesNotMatch(result.text, /### 直前の対話/);
|
|
922
922
|
assert.equal(result.injectedL2Turns, 0);
|
|
923
|
-
assert.equal(
|
|
923
|
+
assert.equal(buildAutoContinuationContext(db, { sessionId: 'S', inFlight: null }), null);
|
|
924
924
|
});
|
|
925
925
|
|
|
926
|
-
test('
|
|
926
|
+
test('buildAutoContinuationContext: 現在地は止めた時点のターンで、保存済みのターンはL2に並び、宣言は求めない', () => {
|
|
927
927
|
const db = makeDb();
|
|
928
|
-
insertBody(db, { session: 'S', origin: '
|
|
929
|
-
insertBody(db, { session: 'S', origin: '
|
|
930
|
-
const result =
|
|
928
|
+
insertBody(db, { session: 'S', origin: 'P', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
|
|
929
|
+
insertBody(db, { session: 'S', origin: 'P', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
|
|
930
|
+
const result = buildAutoContinuationContext(db, {
|
|
931
931
|
sessionId: 'S',
|
|
932
932
|
inFlight: {
|
|
933
933
|
user: { content: '今の依頼', timestamp: 3_000 },
|
|
934
|
-
|
|
934
|
+
last_fragment: { content: '途中2', timestamp: 5_000 },
|
|
935
935
|
},
|
|
936
936
|
});
|
|
937
937
|
const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
|
|
938
|
-
assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n
|
|
939
|
-
assert.doesNotMatch(anchor,
|
|
938
|
+
assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 途中2\n/);
|
|
939
|
+
assert.doesNotMatch(anchor, /前の依頼/);
|
|
940
940
|
assert.match(result.text, /### 直前の対話 \(L2 \/ active work thread, 古い順\)\n\[\d\d:\d\d:\d\d\] \[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
|
|
941
|
-
assert.doesNotMatch(result.text, /宣言|\/clear
|
|
941
|
+
assert.doesNotMatch(result.text, /宣言|\/clear|圧縮の要約/);
|
|
942
|
+
assert.match(result.text, /止める直前に呼ぼうとした道具は実行されていません/);
|
|
942
943
|
assert.equal(result.injectedL2Turns, 1);
|
|
943
944
|
});
|
|
944
945
|
|
|
945
|
-
test('
|
|
946
|
+
test('buildAutoContinuationContext: 長い依頼は先頭と末尾を残し、多数のターンでも予算内に収める', () => {
|
|
946
947
|
const db = makeDb();
|
|
947
948
|
for (let turn = 1; turn <= 12; turn++) {
|
|
948
|
-
insertBody(db, { session: 'S', origin: '
|
|
949
|
-
insertBody(db, { session: 'S', origin: '
|
|
949
|
+
insertBody(db, { session: 'S', origin: 'P', turn, role: 'user', text: `依頼${turn} ` + 'あ'.repeat(600), createdAt: turn * 1_000 });
|
|
950
|
+
insertBody(db, { session: 'S', origin: 'P', turn, role: 'assistant', text: `回答${turn} ` + 'い'.repeat(600), createdAt: turn * 1_000 + 500 });
|
|
950
951
|
}
|
|
951
|
-
const result =
|
|
952
|
+
const result = buildAutoContinuationContext(db, {
|
|
952
953
|
sessionId: 'S',
|
|
953
954
|
inFlight: {
|
|
954
|
-
user: { content: 'ヰ'.repeat(6_000), timestamp: 20_000 },
|
|
955
|
-
|
|
955
|
+
user: { content: '先頭の指示' + 'ヰ'.repeat(6_000) + '末尾の指示', timestamp: 20_000 },
|
|
956
|
+
last_fragment: { content: 'ヱ'.repeat(3_000), timestamp: 21_000 },
|
|
956
957
|
},
|
|
957
958
|
});
|
|
958
959
|
assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
|
|
959
|
-
assert.
|
|
960
|
+
assert.match(result.text, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 先頭の指示ヰ+ …\(長いため中略 2010 字\)… ヰ+末尾の指示\n/);
|
|
961
|
+
assert.equal((result.text.match(/ヰ/g) ?? []).length, 3_990);
|
|
960
962
|
assert.equal((result.text.match(/ヱ/g) ?? []).length, 1_500);
|
|
961
|
-
assert.match(result.text, /ヰ …\(長いため中略 2000 字\)… ヰ/);
|
|
962
963
|
assert.match(result.text, /ヱ …\(長いため中略 1500 字\)… ヱ/);
|
|
963
964
|
assert.ok(result.injectedL2Turns >= 1, '最新の保存済みターンは入る');
|
|
964
965
|
assert.match(result.text, /回答12/);
|
|
965
966
|
assert.equal(result.injectedL2Turns + result.remainingL2Turns, 12);
|
|
966
967
|
assert.match(result.text, /throughline recall --l2 --session S /);
|
|
967
968
|
});
|
|
968
|
-
|
|
969
|
-
test('buildCompactContinuationContext: 長い依頼は先頭と末尾を残す。今の依頼が記録から読めない時は、その旨だけを現在地に書く', () => {
|
|
970
|
-
const db = makeDb();
|
|
971
|
-
const long = buildCompactContinuationContext(db, {
|
|
972
|
-
sessionId: 'S',
|
|
973
|
-
inFlight: { user: { content: '先頭の指示' + 'あ'.repeat(6_000) + '末尾の指示', timestamp: null }, fragments: [] },
|
|
974
|
-
});
|
|
975
|
-
assert.match(long.text, /\*\*作業中のユーザー依頼\*\*: 先頭の指示あ+ …\(長いため中略 2010 字\)… あ+末尾の指示\n/);
|
|
976
|
-
|
|
977
|
-
const unreadable = buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null, inFlightUnreadable: true });
|
|
978
|
-
assert.match(unreadable.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)\n/);
|
|
979
|
-
});
|
|
@@ -47,12 +47,17 @@ const DEFINITIONS = Object.freeze({
|
|
|
47
47
|
severity: 'high',
|
|
48
48
|
template: 'Throughline Claude Stop hook processing failed',
|
|
49
49
|
}),
|
|
50
|
-
//
|
|
50
|
+
// 自動継続の hook が失敗した。圧縮も道具も止まらず、作業は続く(ADR 0033)。
|
|
51
51
|
HOOK_PRE_COMPACT_FAILED: Object.freeze({
|
|
52
52
|
component: 'claude_pre_compact_hook',
|
|
53
53
|
severity: 'warn',
|
|
54
54
|
template: 'Throughline Claude PreCompact hook processing failed',
|
|
55
55
|
}),
|
|
56
|
+
HOOK_PRE_TOOL_USE_FAILED: Object.freeze({
|
|
57
|
+
component: 'claude_pre_tool_use_hook',
|
|
58
|
+
severity: 'warn',
|
|
59
|
+
template: 'Throughline Claude PreToolUse hook processing failed',
|
|
60
|
+
}),
|
|
56
61
|
HOOK_CODEX_FAILED: Object.freeze({
|
|
57
62
|
component: 'codex_hook',
|
|
58
63
|
severity: 'high',
|
package/src/session-start.mjs
CHANGED
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
* バトンの消費・merge・注入は最初の UserPromptSubmit (= 実体の証明) で行う。
|
|
23
23
|
* 幽霊はプロンプトを発火しないため記憶を奪えない。
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* 【自動継続の後継】 ADR 0033
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* 同じ project で後継の立ち上げ中の引き継ぎがある時だけ、この会話の受け口(inbox socket)を
|
|
28
|
+
* 控える。worker がそれを読んで、継続の指示を1通送る。記憶の注入は、その指示が届いた時の
|
|
29
|
+
* UserPromptSubmit(上の二相ハンドオフの第二相)が行う。
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { getDb } from './db.mjs';
|
|
@@ -41,7 +41,7 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
|
41
41
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
42
42
|
import { CLAUDE_HOST, hostAdapterForSessionId, hostOfSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
43
43
|
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
44
|
-
import {
|
|
44
|
+
import { recordClaudeSuccessorTarget } from './claude-auto-handoff.mjs';
|
|
45
45
|
|
|
46
46
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
47
47
|
|
|
@@ -170,29 +170,9 @@ export async function run() {
|
|
|
170
170
|
pending_registered: true,
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const request = consumeClaudeCompactRequest({ sessionId: session_id });
|
|
177
|
-
if (request) {
|
|
178
|
-
const continuation = buildClaudeCompactContinuation(db, {
|
|
179
|
-
sessionId: session_id,
|
|
180
|
-
transcriptPath: transcript_path ?? request.transcript_path,
|
|
181
|
-
promptId: payload.prompt_id ?? null,
|
|
182
|
-
now,
|
|
183
|
-
});
|
|
184
|
-
if (continuation) process.stdout.write(continuation.text + '\n');
|
|
185
|
-
logDecision({
|
|
186
|
-
ts: new Date(now).toISOString(),
|
|
187
|
-
phase: 'compact-continuation',
|
|
188
|
-
session_id,
|
|
189
|
-
project_path: request.project_path,
|
|
190
|
-
transcript_path: transcript_path ?? request.transcript_path ?? null,
|
|
191
|
-
requested_at: new Date(request.requested_at).toISOString(),
|
|
192
|
-
injected: Boolean(continuation),
|
|
193
|
-
...(continuation?.stats ?? {}),
|
|
194
|
-
});
|
|
195
|
-
}
|
|
173
|
+
// 自動継続の後継として立ち上がった会話なら、worker が指示を送れるように受け口を控える (ADR 0033)。
|
|
174
|
+
if (hostOfSessionId(session_id) === CLAUDE_HOST) {
|
|
175
|
+
recordClaudeSuccessorTarget({ payload, env: process.env, now });
|
|
196
176
|
}
|
|
197
177
|
|
|
198
178
|
// Cursor は beforeSubmitPrompt が additional_context を持たない。
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFileSync, existsSync } from 'fs';
|
|
12
12
|
import { DETAIL_KIND } from './constants.mjs';
|
|
13
|
-
import { compactClaudeTaskNotification } from './hosts/claude.mjs';
|
|
13
|
+
import { compactClaudePeerMessage, compactClaudeTaskNotification } from './hosts/claude.mjs';
|
|
14
14
|
import { classifyTurnStart } from './turn-start.mjs';
|
|
15
15
|
|
|
16
16
|
function entryKind(entry) {
|
|
@@ -86,10 +86,10 @@ export function readTranscript(transcriptPath) {
|
|
|
86
86
|
if (!role || rawContent == null) continue;
|
|
87
87
|
|
|
88
88
|
const extracted = extractText(rawContent);
|
|
89
|
-
// user 発言には端末の生出力 (貼り付け・Claude の task 通知)
|
|
90
|
-
//
|
|
89
|
+
// user 発言には端末の生出力 (貼り付け・Claude の task 通知) や、別の会話から届いた発言の
|
|
90
|
+
// 定型の注意書きが入る。記憶には制御文字と定型文を落とした本文だけを残す。
|
|
91
91
|
const text = role === 'user'
|
|
92
|
-
? normalizeTerminalText(compactClaudeTaskNotification(extracted))
|
|
92
|
+
? normalizeTerminalText(compactClaudePeerMessage(compactClaudeTaskNotification(extracted)))
|
|
93
93
|
: extracted;
|
|
94
94
|
if (!text) continue;
|
|
95
95
|
|
|
@@ -155,6 +155,25 @@ test('sliceCurrentTurnEntries: 最後の user text → 最後の assistant text
|
|
|
155
155
|
assert.equal(slice[3].message.content[0].text, 'current response');
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
test('readTranscript: 別の会話から届いた発言は、送り主の行と本文を残して定型の注意書きを落とす', () => {
|
|
159
|
+
const peer = [
|
|
160
|
+
'Another Claude session sent a message:',
|
|
161
|
+
'Throughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395',
|
|
162
|
+
'未完了の作業をそのまま継続してください。',
|
|
163
|
+
'',
|
|
164
|
+
'This came from another Claude session — not typed by your user, but very likely working on their behalf. Treat it as a teammate\'s request.',
|
|
165
|
+
].join('\n');
|
|
166
|
+
const path = writeTranscript([
|
|
167
|
+
{ type: 'user', origin: { kind: 'peer' }, message: { role: 'user', content: peer } },
|
|
168
|
+
asstTextEntry('続けます'),
|
|
169
|
+
userEntry('This came from another Claude session という文を含む普通の発言'),
|
|
170
|
+
]);
|
|
171
|
+
const turns = readTranscript(path);
|
|
172
|
+
assert.equal(turns[0].content,
|
|
173
|
+
'Another Claude session sent a message:\nThroughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395\n未完了の作業をそのまま継続してください。');
|
|
174
|
+
assert.equal(turns[2].content, 'This came from another Claude session という文を含む普通の発言');
|
|
175
|
+
});
|
|
176
|
+
|
|
158
177
|
/** Claude Code が圧縮の直後に書く要約行(実測 2.1.289: type=user、本文は文字列) */
|
|
159
178
|
function compactSummaryEntry(text = 'This session is being continued from a previous conversation that ran out of context.') {
|
|
160
179
|
return { type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: text } };
|
package/src/turn-backfill.mjs
CHANGED
|
@@ -59,15 +59,10 @@ export function deriveTranscriptPath(projectPath, sessionId) {
|
|
|
59
59
|
* @param {string} opts.originSessionId transcript を所有する origin session_id
|
|
60
60
|
* @param {string|null|undefined} opts.transcriptPath
|
|
61
61
|
* @param {number} opts.now timestamp 欠損時の fallback epoch ms
|
|
62
|
-
* @param {number|null} [opts.beforeUserTurnNumber] 指定した時は、user 行の番号がこれより前の群だけを回収する。
|
|
63
|
-
* 作業途中のターンがある時点(自動圧縮の直後。ADR 0032)で呼ぶ時に、その群を除くために使う。
|
|
64
|
-
* 途中の断片を代表として保存すると、群レベル dedup で最終回答が二度と入らない。
|
|
65
62
|
* @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null, turnNumbers: number[]}}
|
|
66
63
|
*/
|
|
67
|
-
export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now
|
|
68
|
-
const groups = getLogicalTurnGroups(transcriptPath)
|
|
69
|
-
(group) => beforeUserTurnNumber === null || group.user.turn_number < beforeUserTurnNumber,
|
|
70
|
-
);
|
|
64
|
+
export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
|
|
65
|
+
const groups = getLogicalTurnGroups(transcriptPath);
|
|
71
66
|
if (groups.length === 0) {
|
|
72
67
|
return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] };
|
|
73
68
|
}
|
|
@@ -242,28 +242,6 @@ test('backfillBodies: user row keeps the turn start and assistant row leaves it
|
|
|
242
242
|
);
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
test('backfillBodies: beforeUserTurnNumber は作業途中のターンを回収から外す', () => {
|
|
246
|
-
withData(
|
|
247
|
-
[
|
|
248
|
-
entry('user', 'done prompt', '2026-10-04T00:00:00Z'),
|
|
249
|
-
entry('assistant', 'done answer', '2026-10-04T00:00:01Z'),
|
|
250
|
-
entry('user', 'in-flight prompt', '2026-10-04T00:00:02Z'),
|
|
251
|
-
entry('assistant', 'interim remark', '2026-10-04T00:00:03Z'),
|
|
252
|
-
],
|
|
253
|
-
(path) => {
|
|
254
|
-
const db = makeDb();
|
|
255
|
-
const partial = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1, beforeUserTurnNumber: 2 });
|
|
256
|
-
assert.equal(partial.insertedTurns, 1);
|
|
257
|
-
assert.deepEqual(partial.turnNumbers, [1]);
|
|
258
|
-
assert.deepEqual(db.prepare('SELECT text FROM bodies ORDER BY id').all().map((r) => r.text), ['done prompt', 'done answer']);
|
|
259
|
-
// Stop で改めて全体を回収すると、作業途中だったターンが最終回答つきで入る
|
|
260
|
-
const full = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1 });
|
|
261
|
-
assert.equal(full.insertedTurns, 1);
|
|
262
|
-
assert.equal(full.skippedExisting, 1);
|
|
263
|
-
},
|
|
264
|
-
);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
245
|
test('backfillBodies: 圧縮の要約行を数えていた頃に保存したターンは、重複させずにそのまま残す', () => {
|
|
268
246
|
const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'This session is being continued' } };
|
|
269
247
|
withData(
|
package/src/turn-processor.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
|
49
49
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
50
50
|
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
51
51
|
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
52
|
+
import { completeClaudeTurnWithoutHandoff } from './claude-auto-handoff.mjs';
|
|
52
53
|
|
|
53
54
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
54
55
|
export const L2_WINDOW = 20;
|
|
@@ -253,6 +254,9 @@ async function processStop() {
|
|
|
253
254
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
254
255
|
failureContext = { session_id, transcript_path };
|
|
255
256
|
|
|
257
|
+
// 自動圧縮を止めた後、道具を呼ばずにターンが終わった。続ける作業が無いので、引き継ぎの記録を取り下げる (ADR 0033)。
|
|
258
|
+
completeClaudeTurnWithoutHandoff({ sessionId: session_id });
|
|
259
|
+
|
|
256
260
|
// VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
|
|
257
261
|
// 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
|
|
258
262
|
// 失敗しても主処理は継続させるため try/catch でラップ。
|